Pass reference bindings to Notarius
This commit is contained in:
@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
f.Requests = append(f.Requests, req)
|
||||
copyRequest := req
|
||||
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
|
||||
f.Requests = append(f.Requests, copyRequest)
|
||||
if f.Err != nil {
|
||||
return RunResult{}, f.Err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ type Runner interface {
|
||||
Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||
}
|
||||
|
||||
// ReferenceBinding maps one normalized Notarius selector to an absolute
|
||||
// external reference path.
|
||||
type ReferenceBinding struct {
|
||||
Selector string
|
||||
Path string
|
||||
}
|
||||
|
||||
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||
type RunRequest struct {
|
||||
Binary string
|
||||
@@ -24,6 +31,7 @@ type RunRequest struct {
|
||||
ReceiptPath string
|
||||
LogPath string
|
||||
Timeout time.Duration
|
||||
References []ReferenceBinding
|
||||
}
|
||||
|
||||
// Receipt is the transport-neutral successful run receipt.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
@@ -47,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
if r == nil || r.run == nil {
|
||||
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
|
||||
}
|
||||
if err := validateRunRequest(req); err != nil {
|
||||
references, err := validateRunRequest(req)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
|
||||
@@ -56,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
"--config", req.ConfigPath,
|
||||
"--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
"--json",
|
||||
}
|
||||
for _, reference := range references {
|
||||
args = append(args, "--reference", reference.Selector+"="+reference.Path)
|
||||
}
|
||||
args = append(args, "--json")
|
||||
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
@@ -128,15 +133,15 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
return baseResult, nil
|
||||
}
|
||||
|
||||
func validateRunRequest(req RunRequest) error {
|
||||
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||
if strings.TrimSpace(req.Binary) == "" {
|
||||
return fmt.Errorf("notarius binary is required")
|
||||
return nil, fmt.Errorf("notarius binary is required")
|
||||
}
|
||||
if strings.TrimSpace(req.PipelineID) == "" {
|
||||
return fmt.Errorf("notarius pipeline id is required")
|
||||
return nil, fmt.Errorf("notarius pipeline id is required")
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
return fmt.Errorf("notarius timeout must be positive")
|
||||
return nil, fmt.Errorf("notarius timeout must be positive")
|
||||
}
|
||||
for label, path := range map[string]string{
|
||||
"config": req.ConfigPath,
|
||||
@@ -147,34 +152,53 @@ func validateRunRequest(req RunRequest) error {
|
||||
"log": req.LogPath,
|
||||
} {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("notarius %s path is required", label)
|
||||
return nil, fmt.Errorf("notarius %s path is required", label)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return fmt.Errorf("notarius %s path must be absolute", label)
|
||||
return nil, fmt.Errorf("notarius %s path must be absolute", label)
|
||||
}
|
||||
}
|
||||
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
|
||||
return fmt.Errorf("notarius receipt and log paths must be different")
|
||||
return nil, fmt.Errorf("notarius receipt and log paths must be different")
|
||||
}
|
||||
references := make([]ReferenceBinding, 0, len(req.References))
|
||||
selectors := make(map[string]struct{}, len(req.References))
|
||||
for index, binding := range req.References {
|
||||
selector, err := notariusref.NormalizeSelector(binding.Selector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
|
||||
}
|
||||
if _, duplicate := selectors[selector]; duplicate {
|
||||
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
|
||||
}
|
||||
selectors[selector] = struct{}{}
|
||||
if strings.TrimSpace(binding.Path) == "" {
|
||||
return nil, fmt.Errorf("notarius reference %q path is required", selector)
|
||||
}
|
||||
if !filepath.IsAbs(binding.Path) {
|
||||
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
|
||||
}
|
||||
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
|
||||
}
|
||||
if err := requireRegularFile(req.ConfigPath); err != nil {
|
||||
return fmt.Errorf("validate notarius config path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius config path: %w", err)
|
||||
}
|
||||
if err := requireRegularFile(req.InputPath); err != nil {
|
||||
return fmt.Errorf("validate notarius input path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius input path: %w", err)
|
||||
}
|
||||
if err := requireDirectory(req.OutputRoot); err != nil {
|
||||
return fmt.Errorf("validate notarius output root: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius output root: %w", err)
|
||||
}
|
||||
if err := requireDirectory(req.WorkingDirectory); err != nil {
|
||||
return fmt.Errorf("validate notarius working directory: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius working directory: %w", err)
|
||||
}
|
||||
if err := validateLogDestination(req.ReceiptPath); err != nil {
|
||||
return fmt.Errorf("validate notarius receipt path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius receipt path: %w", err)
|
||||
}
|
||||
if err := validateLogDestination(req.LogPath); err != nil {
|
||||
return fmt.Errorf("validate notarius log path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius log path: %w", err)
|
||||
}
|
||||
return nil
|
||||
return references, nil
|
||||
}
|
||||
|
||||
type receiptDocument struct {
|
||||
|
||||
@@ -76,6 +76,101 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
referenceRoot := t.TempDir()
|
||||
req.References = []ReferenceBinding{
|
||||
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
|
||||
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
|
||||
}
|
||||
originalReferences := append([]ReferenceBinding(nil), req.References...)
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
wantArgs := []string{
|
||||
"run", req.PipelineID,
|
||||
"--config", req.ConfigPath,
|
||||
"--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
"--reference", "party=" + req.References[0].Path,
|
||||
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
|
||||
"--json",
|
||||
}
|
||||
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||
}
|
||||
if !reflect.DeepEqual(req.References, originalReferences) {
|
||||
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
references func(string) []ReferenceBinding
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid selector",
|
||||
references: func(root string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
|
||||
},
|
||||
wantErr: "selector",
|
||||
},
|
||||
{
|
||||
name: "duplicate normalized selector",
|
||||
references: func(root string) []ReferenceBinding {
|
||||
return []ReferenceBinding{
|
||||
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
|
||||
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
|
||||
}
|
||||
},
|
||||
wantErr: "duplicated",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
references: func(string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "party", Path: " "}}
|
||||
},
|
||||
wantErr: "path is required",
|
||||
},
|
||||
{
|
||||
name: "relative path",
|
||||
references: func(string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
|
||||
},
|
||||
wantErr: "path must be absolute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.References = tt.references(t.TempDir())
|
||||
started := false
|
||||
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
started = true
|
||||
return sharedsubprocess.RunResult{}, nil
|
||||
}}
|
||||
|
||||
_, err := runner.Run(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if started {
|
||||
t.Fatal("subprocess started after request validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
@@ -432,13 +527,17 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
||||
}
|
||||
|
||||
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
||||
req := RunRequest{PipelineID: "pipeline"}
|
||||
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
|
||||
want := RunResult{BundleRoot: "/bundle"}
|
||||
fake := &FakeRunner{Result: want}
|
||||
got, err := fake.Run(context.Background(), req)
|
||||
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
|
||||
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
|
||||
}
|
||||
req.References[0].Path = "/references/changed.json"
|
||||
if fake.Requests[0].References[0].Path != "/references/party.json" {
|
||||
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
|
||||
}
|
||||
|
||||
wantErr := errors.New("configured failure")
|
||||
fake.Err = wantErr
|
||||
|
||||
Reference in New Issue
Block a user