Bind prepared references to extraction identity
This commit is contained in:
@@ -20,7 +20,7 @@ different contract.
|
|||||||
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
|
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
|
||||||
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
|
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
|
||||||
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
|
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
|
||||||
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Pending |
|
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Completed |
|
||||||
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending |
|
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending |
|
||||||
| 7 | Update canonical documentation and maintained examples for the completed feature. | Pending |
|
| 7 | Update canonical documentation and maintained examples for the completed feature. | Pending |
|
||||||
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Pending |
|
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Pending |
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: resolve final-trimmed transcript identity: %w", err)
|
return nil, fmt.Errorf("extract: resolve final-trimmed transcript identity: %w", err)
|
||||||
}
|
}
|
||||||
|
references, err := resolveExtractReferences(paths, m, notariusConfig, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: resolve Notarius references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
|
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
|
||||||
if err != nil || timeout <= 0 {
|
if err != nil || timeout <= 0 {
|
||||||
@@ -125,14 +129,14 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input)
|
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input, references.Identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
request := notarius.RunRequest{
|
request := notarius.RunRequest{
|
||||||
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||||
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
||||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout,
|
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: references.Bindings,
|
||||||
}
|
}
|
||||||
adapterResult, err := env.Notarius.Run(ctx, request)
|
adapterResult, err := env.Notarius.Run(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,6 +253,8 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
"narratio_run_id": runID,
|
"narratio_run_id": runID,
|
||||||
"configuration_fingerprint": fingerprint,
|
"configuration_fingerprint": fingerprint,
|
||||||
"direct_input": input.Metadata(),
|
"direct_input": input.Metadata(),
|
||||||
|
"reference_count": len(references.Identities),
|
||||||
|
"references": extractReferenceMetadata(references.Identities),
|
||||||
"receipt": map[string]any{
|
"receipt": map[string]any{
|
||||||
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
|
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
|
||||||
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
|
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
|
||||||
@@ -379,6 +385,7 @@ type fingerprintDocument struct {
|
|||||||
Timeout string `json:"timeout"`
|
Timeout string `json:"timeout"`
|
||||||
WorkingDirectory string `json:"working_directory"`
|
WorkingDirectory string `json:"working_directory"`
|
||||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||||
|
References []extractReferenceIdentity `json:"references"`
|
||||||
Outputs []fingerprintOutput `json:"outputs"`
|
Outputs []fingerprintOutput `json:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +395,7 @@ func extractionFingerprint(
|
|||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
workingDirectory string,
|
workingDirectory string,
|
||||||
input artifacts.ExtractionInputIdentity,
|
input artifacts.ExtractionInputIdentity,
|
||||||
|
references []extractReferenceIdentity,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
keys := make([]string, 0, len(cfg.Outputs))
|
keys := make([]string, 0, len(cfg.Outputs))
|
||||||
for key := range cfg.Outputs {
|
for key := range cfg.Outputs {
|
||||||
@@ -402,9 +410,20 @@ func extractionFingerprint(
|
|||||||
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
sortedReferences := append([]extractReferenceIdentity(nil), references...)
|
||||||
|
sort.Slice(sortedReferences, func(i, j int) bool {
|
||||||
|
if sortedReferences[i].Selector != sortedReferences[j].Selector {
|
||||||
|
return sortedReferences[i].Selector < sortedReferences[j].Selector
|
||||||
|
}
|
||||||
|
if sortedReferences[i].SourceID != sortedReferences[j].SourceID {
|
||||||
|
return sortedReferences[i].SourceID < sortedReferences[j].SourceID
|
||||||
|
}
|
||||||
|
return sortedReferences[i].Path < sortedReferences[j].Path
|
||||||
|
})
|
||||||
payload, err := json.Marshal(fingerprintDocument{
|
payload, err := json.Marshal(fingerprintDocument{
|
||||||
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
||||||
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input, Outputs: outputs,
|
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input,
|
||||||
|
References: sortedReferences, Outputs: outputs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
107
internal/stage/extract_references.go
Normal file
107
internal/stage/extract_references.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractReferenceIdentity struct {
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedExtractReferences struct {
|
||||||
|
Bindings []notarius.ReferenceBinding
|
||||||
|
Identities []extractReferenceIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExtractReferences(
|
||||||
|
paths artifacts.SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
cfg *config.NotariusConfig,
|
||||||
|
sessionID string,
|
||||||
|
) (resolvedExtractReferences, error) {
|
||||||
|
if cfg == nil || len(cfg.References) == 0 {
|
||||||
|
return resolvedExtractReferences{
|
||||||
|
Bindings: []notarius.ReferenceBinding{},
|
||||||
|
Identities: []extractReferenceIdentity{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type configuredReference struct {
|
||||||
|
selector string
|
||||||
|
sourceID string
|
||||||
|
}
|
||||||
|
configured := make([]configuredReference, 0, len(cfg.References))
|
||||||
|
seen := make(map[string]struct{}, len(cfg.References))
|
||||||
|
for rawSelector, rawSourceID := range cfg.References {
|
||||||
|
selector, err := notariusref.NormalizeSelector(rawSelector)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is invalid: %w", rawSelector, err)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[selector]; duplicate {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is configured more than once after normalization", selector)
|
||||||
|
}
|
||||||
|
seen[selector] = struct{}{}
|
||||||
|
configured = append(configured, configuredReference{selector: selector, sourceID: strings.TrimSpace(rawSourceID)})
|
||||||
|
}
|
||||||
|
sort.Slice(configured, func(i, j int) bool { return configured[i].selector < configured[j].selector })
|
||||||
|
resolved := resolvedExtractReferences{
|
||||||
|
Bindings: make([]notarius.ReferenceBinding, 0, len(configured)),
|
||||||
|
Identities: make([]extractReferenceIdentity, 0, len(configured)),
|
||||||
|
}
|
||||||
|
for _, reference := range configured {
|
||||||
|
selector := reference.selector
|
||||||
|
sourceID := reference.sourceID
|
||||||
|
identity, err := artifacts.ResolvePreparedInput(paths, m, sourceID)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf(
|
||||||
|
"reference %q source %q is unavailable or invalid; run narratio run-stage prepare %s --force: %w",
|
||||||
|
selector,
|
||||||
|
sourceID,
|
||||||
|
sessionID,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resolved.Bindings = append(resolved.Bindings, notarius.ReferenceBinding{
|
||||||
|
Selector: selector,
|
||||||
|
Path: identity.Path,
|
||||||
|
})
|
||||||
|
resolved.Identities = append(resolved.Identities, extractReferenceIdentity{
|
||||||
|
Selector: selector,
|
||||||
|
SourceID: identity.SourceID,
|
||||||
|
Path: identity.RelativePath,
|
||||||
|
Checksum: identity.Checksum,
|
||||||
|
SizeBytes: identity.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractReferenceMetadata(identities []extractReferenceIdentity) []map[string]any {
|
||||||
|
limit := len(identities)
|
||||||
|
if limit > config.MaxNotariusReferenceBindings {
|
||||||
|
limit = config.MaxNotariusReferenceBindings
|
||||||
|
}
|
||||||
|
metadata := make([]map[string]any, 0, limit)
|
||||||
|
for _, identity := range identities[:limit] {
|
||||||
|
metadata = append(metadata, map[string]any{
|
||||||
|
"selector": identity.Selector,
|
||||||
|
"source_id": identity.SourceID,
|
||||||
|
"path": identity.Path,
|
||||||
|
"checksum": identity.Checksum,
|
||||||
|
"size_bytes": identity.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
@@ -49,7 +49,11 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err)
|
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err)
|
||||||
}
|
}
|
||||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input)
|
references, err := resolveExtractReferences(paths, m, cfg, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius references: %w", err)
|
||||||
|
}
|
||||||
|
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input, references.Identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
|
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -75,11 +76,108 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
|||||||
if !filepath.IsAbs(req.Binary) {
|
if !filepath.IsAbs(req.Binary) {
|
||||||
t.Fatalf("binary = %q, want absolute resolved path", req.Binary)
|
t.Fatalf("binary = %q, want absolute resolved path", req.Binary)
|
||||||
}
|
}
|
||||||
|
if len(req.References) != 0 {
|
||||||
|
t.Fatalf("references = %#v, want none", req.References)
|
||||||
|
}
|
||||||
|
if result.Metadata["reference_count"] != 0 || len(result.Metadata["references"].([]map[string]any)) != 0 {
|
||||||
|
t.Fatalf("reference metadata = count %#v references %#v", result.Metadata["reference_count"], result.Metadata["references"])
|
||||||
|
}
|
||||||
if len(result.Outputs) != 2 {
|
if len(result.Outputs) != 2 {
|
||||||
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
configured := []struct {
|
||||||
|
selector string
|
||||||
|
sourceID string
|
||||||
|
contents string
|
||||||
|
}{
|
||||||
|
{selector: " players ", sourceID: artifactpolicy.SourceInputPlayers, contents: "players-secret\n"},
|
||||||
|
{selector: "spells.spell_catalog", sourceID: artifactpolicy.SourceInputSpellCatalog, contents: "spell-secret\n"},
|
||||||
|
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, contents: "glossary-secret\n"},
|
||||||
|
{selector: "party", sourceID: artifactpolicy.SourceInputParty, contents: "party-secret\n"},
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Notarius.References = make(map[string]string, len(configured))
|
||||||
|
wantPaths := make(map[string]string, len(configured))
|
||||||
|
wantSources := make(map[string]string, len(configured))
|
||||||
|
for _, reference := range configured {
|
||||||
|
env.Config.Pipeline.Notarius.References[reference.selector] = reference.sourceID
|
||||||
|
selector := strings.TrimSpace(reference.selector)
|
||||||
|
wantPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
|
||||||
|
wantSources[selector] = reference.sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
wantSelectors := []string{"glossary", "party", "players", "spells.spell_catalog"}
|
||||||
|
if len(fake.Requests) != 1 || len(fake.Requests[0].References) != len(wantSelectors) {
|
||||||
|
t.Fatalf("requests = %#v", fake.Requests)
|
||||||
|
}
|
||||||
|
for index, selector := range wantSelectors {
|
||||||
|
binding := fake.Requests[0].References[index]
|
||||||
|
if binding.Selector != selector || binding.Path != wantPaths[selector] || !filepath.IsAbs(binding.Path) {
|
||||||
|
t.Fatalf("reference[%d] = %#v, want selector %q path %q", index, binding, selector, wantPaths[selector])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Metadata["reference_count"] != len(wantSelectors) {
|
||||||
|
t.Fatalf("reference_count = %#v", result.Metadata["reference_count"])
|
||||||
|
}
|
||||||
|
metadata, ok := result.Metadata["references"].([]map[string]any)
|
||||||
|
if !ok || len(metadata) != len(wantSelectors) {
|
||||||
|
t.Fatalf("references metadata = %#v", result.Metadata["references"])
|
||||||
|
}
|
||||||
|
for index, selector := range wantSelectors {
|
||||||
|
entry := metadata[index]
|
||||||
|
if len(entry) != 5 || entry["selector"] != selector || entry["source_id"] != wantSources[selector] {
|
||||||
|
t.Fatalf("reference metadata[%d] = %#v", index, entry)
|
||||||
|
}
|
||||||
|
if path, _ := entry["path"].(string); !strings.HasPrefix(path, "inputs/") || filepath.IsAbs(path) {
|
||||||
|
t.Fatalf("reference metadata path = %#v", entry["path"])
|
||||||
|
}
|
||||||
|
if entry["checksum"] == "" || entry["size_bytes"] == int64(0) {
|
||||||
|
t.Fatalf("reference identity metadata = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(result.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(metadata) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, reference := range configured {
|
||||||
|
if strings.Contains(string(encoded), strings.TrimSpace(reference.contents)) {
|
||||||
|
t.Fatalf("metadata contains reference payload %q: %s", reference.contents, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageRejectsUnavailableReferenceBeforeInvocationOrRunDirectoryCreation(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"spell_catalog": artifactpolicy.SourceInputSpellCatalog,
|
||||||
|
}
|
||||||
|
runRoot := artifacts.SessionRunRootForCampaign(
|
||||||
|
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
|
||||||
|
)
|
||||||
|
if err := os.RemoveAll(runRoot); err != nil {
|
||||||
|
t.Fatalf("RemoveAll(run root) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("Run() result = %#v error = %v", result, err)
|
||||||
|
}
|
||||||
|
if len(fake.Requests) != 0 {
|
||||||
|
t.Fatalf("adapter requests = %#v", fake.Requests)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(runRoot); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("run root stat error = %v, want not exist", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
||||||
env, m, fake := setupExtractEnv(t)
|
env, m, fake := setupExtractEnv(t)
|
||||||
fixture := extractFixtureFromEnv(t, env, m)
|
fixture := extractFixtureFromEnv(t, env, m)
|
||||||
@@ -360,11 +458,15 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
second := &config.NotariusConfig{PipelineID: "pipeline", Outputs: map[string]config.NotariusOutputConfig{
|
second := &config.NotariusConfig{PipelineID: "pipeline", Outputs: map[string]config.NotariusOutputConfig{
|
||||||
"alpha": first.Outputs["alpha"], "zeta": first.Outputs["zeta"],
|
"alpha": first.Outputs["alpha"], "zeta": first.Outputs["zeta"],
|
||||||
}}
|
}}
|
||||||
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input)
|
references := []extractReferenceIdentity{
|
||||||
|
{Selector: "zeta", SourceID: artifactpolicy.SourceInputPlayers, Path: "inputs/players.yml", Checksum: "players-checksum", SizeBytes: 12},
|
||||||
|
{Selector: "alpha", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml", Checksum: "party-checksum", SizeBytes: 34},
|
||||||
|
}
|
||||||
|
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input, references)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(first) error = %v", err)
|
t.Fatalf("extractionFingerprint(first) error = %v", err)
|
||||||
}
|
}
|
||||||
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input)
|
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, []extractReferenceIdentity{references[1], references[0]})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(second) error = %v", err)
|
t.Fatalf("extractionFingerprint(second) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -384,7 +486,7 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
changedInput := input
|
changedInput := input
|
||||||
test.mutate(&changedInput)
|
test.mutate(&changedInput)
|
||||||
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput)
|
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput, references)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
|
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -393,10 +495,36 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*extractReferenceIdentity)
|
||||||
|
}{
|
||||||
|
{name: "selector", mutate: func(identity *extractReferenceIdentity) { identity.Selector = "changed" }},
|
||||||
|
{name: "source", mutate: func(identity *extractReferenceIdentity) { identity.SourceID = artifactpolicy.SourceInputGlossary }},
|
||||||
|
{name: "path", mutate: func(identity *extractReferenceIdentity) { identity.Path = "inputs/changed.yml" }},
|
||||||
|
{name: "checksum", mutate: func(identity *extractReferenceIdentity) { identity.Checksum = "changed-checksum" }},
|
||||||
|
{name: "size", mutate: func(identity *extractReferenceIdentity) { identity.SizeBytes++ }},
|
||||||
|
} {
|
||||||
|
t.Run("reference "+test.name, func(t *testing.T) {
|
||||||
|
changedReferences := append([]extractReferenceIdentity(nil), references...)
|
||||||
|
test.mutate(&changedReferences[0])
|
||||||
|
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, changedReferences)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractionFingerprint(changed reference) error = %v", err)
|
||||||
|
}
|
||||||
|
if one == changed {
|
||||||
|
t.Fatalf("fingerprint did not change with reference %s", test.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
|
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
|
||||||
env, m, _ := setupExtractEnv(t)
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party-reference\n")
|
||||||
seedSucceededExtractResult(t, env, m)
|
seedSucceededExtractResult(t, env, m)
|
||||||
m.RunID = "20260810T020304Z-fedcba98"
|
m.RunID = "20260810T020304Z-fedcba98"
|
||||||
|
|
||||||
@@ -409,6 +537,93 @@ func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationRejectsChangedPreparedReferenceIdentity(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
|
||||||
|
seedSucceededExtractResult(t, env, m)
|
||||||
|
if err := os.WriteFile(path, []byte("changed-party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHA256File(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||||
|
for index := range m.Inputs {
|
||||||
|
if m.Inputs[index].Kind == descriptor.ManifestKind {
|
||||||
|
m.Inputs[index].Checksum = checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateResume() error = %v", err)
|
||||||
|
}
|
||||||
|
if validation.Resumable || !strings.Contains(validation.Reason, "invocation contract changed") {
|
||||||
|
t.Fatalf("validation = %#v, want changed invocation contract", validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationErrorsForUnavailablePreparedReference(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*testing.T, string, *manifest.Manifest)
|
||||||
|
}{
|
||||||
|
{name: "missing", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatalf("Remove(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "unsafe manifest path", mutate: func(t *testing.T, _ string, m *manifest.Manifest) {
|
||||||
|
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||||
|
for index := range m.Inputs {
|
||||||
|
if m.Inputs[index].Kind == descriptor.ManifestKind {
|
||||||
|
m.Inputs[index].Path = filepath.Join(t.TempDir(), "outside.yml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "stale checksum", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
|
||||||
|
if err := os.WriteFile(path, []byte("tampered-party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
|
||||||
|
seedSucceededExtractResult(t, env, m)
|
||||||
|
test.mutate(t, path, m)
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("ValidateResume() validation = %#v error = %v", validation, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationDoesNotResolveReferencesWhenDisabled(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.Enabled = false
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"missing": artifactpolicy.SourceInputSpellCatalog,
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateResume() error = %v", err)
|
||||||
|
}
|
||||||
|
if validation.Resumable || !strings.Contains(validation.Reason, "disabled") {
|
||||||
|
t.Fatalf("validation = %#v", validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *testing.T) {
|
func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *testing.T) {
|
||||||
env, m, fake := setupExtractEnv(t)
|
env, m, fake := setupExtractEnv(t)
|
||||||
producerRunID := m.RunID
|
producerRunID := m.RunID
|
||||||
@@ -622,6 +837,29 @@ func extractFixtureFromEnv(t *testing.T, env *Env, m *manifest.Manifest) extract
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordPreparedExtractInput(t *testing.T, env *Env, m *manifest.Manifest, sourceID, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
|
||||||
|
}
|
||||||
|
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, descriptor.Filename)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHA256File(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
m.Inputs = append(m.Inputs, manifest.InputRecord{
|
||||||
|
Kind: descriptor.ManifestKind, Path: path, Checksum: checksum,
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunner) {
|
func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunner) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user