Publish and inspect configured extraction artifacts

This commit is contained in:
2026-08-10 00:32:24 +00:00
parent 39af7d4f3c
commit 0341e0c7c0
13 changed files with 466 additions and 38 deletions

View File

@@ -23,7 +23,7 @@ implementation sequence.
| Stage 5 | Complete |
| Stage 6 | Complete |
| Stage 7 | Complete |
| Stage 8 | Not started |
| Stage 8 | Complete |
| Stage 9 | Not started |
After completing and validating a stage, update only that stage's row to

View File

@@ -10,9 +10,10 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog, error) {
func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*artifacts.ArtifactCatalog, error) {
catalog := artifacts.NewArtifactCatalog()
if err := catalog.RegisterBuiltIns(); err != nil {
return nil, err
@@ -26,6 +27,14 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
return nil, err
}
extractionDefinitions := artifacts.ExtractionDefinitionsFromConfig(cfg.Pipeline.Notarius)
if err := catalog.RegisterExtractionArtifacts(extractionDefinitions); err != nil {
return nil, err
}
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
return catalog, nil
}
@@ -40,6 +49,14 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
for _, entry := range catalog.ListConfigured() {
writeArtifactLine(out, entry.SourceID, lockSet)
}
fmt.Fprintln(out, "Extraction:")
for _, entry := range catalog.ListExtraction() {
state := "unavailable"
if entry.Available {
state = "available"
}
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
}
fmt.Fprintln(out, "Previous-session:")
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
fmt.Fprintf(out, "- %s required=%t\n", artifactpolicy.PreviousSessionSourceID(req.Name), req.Required)
@@ -50,6 +67,17 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
}
}
func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
parts := []string{source, "planned", state}
if strings.TrimSpace(provenance) != "" {
parts = append(parts, "provenance="+strings.TrimSpace(provenance))
}
if _, ok := lockSet[source]; ok {
parts = append(parts, "locked")
}
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
}
func writeArtifactLine(out io.Writer, source string, lockSet map[string]config.PublishLockRule) {
parts := []string{source}
if _, ok := lockSet[source]; ok {
@@ -103,7 +131,12 @@ func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config,
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
source := strings.TrimSpace(rule.Source)
normalized, err := artifactpolicy.ResolvePublishedDestination(source, rule.Dest, helperConfiguredOutputPathMap(catalog))
normalized, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(
source,
rule.Dest,
helperConfiguredOutputPathMap(catalog),
helperExtractionOutputSet(catalog),
)
if err != nil {
return "", false, err
}
@@ -112,6 +145,19 @@ func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts
return normalized, showDest, nil
}
func helperExtractionOutputSet(catalog *artifacts.ArtifactCatalog) map[string]struct{} {
out := map[string]struct{}{}
if catalog == nil {
return out
}
for _, entry := range catalog.ListExtraction() {
if strings.TrimSpace(entry.ExtractionKey) != "" {
out[entry.ExtractionKey] = struct{}{}
}
}
return out
}
func helperConfiguredOutputPathMap(catalog *artifacts.ArtifactCatalog) map[string]string {
out := map[string]string{}
if catalog == nil {

View File

@@ -22,11 +22,11 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("artifacts list: session_id is required")
}
cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote)
cfg, store, locks, m, err := loadHelperContext(ctx, flags, remote)
if err != nil {
return fmt.Errorf("artifacts list: %w", err)
}
catalog, err := buildHelperArtifactCatalog(cfg)
catalog, err := buildHelperArtifactCatalog(cfg, m)
if err != nil {
return fmt.Errorf("artifacts list: %w", err)
}

View File

@@ -8,8 +8,10 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -772,6 +774,71 @@ func TestExecuteArtifactsListRemoteReportsPublishedAvailability(t *testing.T) {
}
}
func TestExecuteArtifactsListReportsExtractionLifecycleWithoutPayload(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addExtractionOutputToPipeline(t, pipelinePath)
addPublishOutputsToPipeline(t, pipelinePath, `
outputs:
- source: narratio.extraction.encounters
dest: artifacts/encounters.json
required: true
`)
lanePath := writeOperatorExtractionManifest(t, workspaceRoot)
fake := &storage.FakeBackend{}
publishedKey := artifacts.S3PublishedOutputKey(
artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"),
"artifacts/encounters.json",
)
fake.SeedObject(storage.FakeObject{Key: publishedKey, Data: []byte(`{"secret":"DO_NOT_PRINT"}`)})
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "artifacts", "2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{
"Extraction:",
"narratio.extraction.encounters planned available provenance=manifest.current_extract_run",
"narratio.extraction.encounters dest=artifacts/encounters.json remote=published",
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout = %q, want %q", out, want)
}
}
if strings.Contains(out, "DO_NOT_PRINT") {
t.Fatalf("operator output exposed extraction payload: %q", out)
}
if err := os.Remove(lanePath); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"session", "artifacts", "2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("unavailable exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "narratio.extraction.encounters planned unavailable") {
t.Fatalf("stdout = %q, want unavailable extraction state", stdout.String())
}
}
func TestExecuteArtifactsListRemoteUsesPublishOutputDestinations(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -1041,6 +1108,72 @@ func addPublishOutputsToPipeline(t *testing.T, pipelinePath, publishYAML string)
}
}
func addExtractionOutputToPipeline(t *testing.T, pipelinePath string) {
t.Helper()
data, err := os.ReadFile(pipelinePath)
if err != nil {
t.Fatal(err)
}
data = append(data, []byte(`notarius:
enabled: true
config_path: notarius.yml
pipeline_id: campaign.extract
outputs:
encounters:
lane_id: encounters
media_type: application/json
schema_id: encounters
schema_version: "1"
module_key: encounters
`)...)
if err := os.WriteFile(pipelinePath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeOperatorExtractionManifest(t *testing.T, workspaceRoot string) string {
t.Helper()
paths := artifacts.NewLocalStore(workspaceRoot).SessionPathsFor("sample-campaign", "2026-05-03")
bundleRoot := filepath.Join(paths.ArtifactsDir, "notarius", "extract-run-1")
lanePath := filepath.Join(bundleRoot, "lanes", "encounters.json")
indexPath := filepath.Join(bundleRoot, "index.json")
mustWriteTestFile(t, lanePath, `{"secret":"DO_NOT_PRINT"}`)
mustWriteTestFile(t, indexPath, `{"lanes":[]}`)
laneChecksum, err := artifacts.SHA256File(lanePath)
if err != nil {
t.Fatal(err)
}
indexChecksum, err := artifacts.SHA256File(indexPath)
if err != nil {
t.Fatal(err)
}
m := manifest.New("2026-05-03", time.Now().UTC())
m.Campaign = "sample-campaign"
m.Stages["extract"] = &manifest.StageRecord{
Name: "extract", Status: manifest.StatusSucceeded,
Metadata: map[string]any{
"narratio_run_id": "extract-run-1", "bundle_root": bundleRoot,
"receipt": map[string]any{"run_id": "notarius-run-1", "pipeline_id": "campaign.extract"},
},
Outputs: []manifest.ArtifactRecord{
{
Kind: "notarius_lane", SourceID: artifacts.ExtractionArtifactSourceID("encounters"), LocalPath: lanePath,
ProducerRunID: "extract-run-1", Checksum: laneChecksum,
Contract: &artifactmodel.ContractMetadata{MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters"},
ExternalProvenance: &artifactmodel.ExternalProvenance{System: "notarius", RunID: "notarius-run-1", PipelineID: "campaign.extract", ArtifactID: "encounters"},
},
{Kind: "notarius_index", LocalPath: indexPath, ProducerRunID: "extract-run-1", Checksum: indexChecksum},
},
}
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
t.Fatal(err)
}
return lanePath
}
func replaceInFileOrFatal(t *testing.T, path, old, new string) {
t.Helper()
data, err := os.ReadFile(path)

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// Status reports effective local/remote session state.
@@ -40,11 +41,13 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
writeStatusStableInputs(out, inspectStableInputs(cfg))
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
var localManifest *manifest.Manifest
if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil {
fmt.Fprintf(out, "Local manifest: error: %v\n", err)
} else if m == nil {
fmt.Fprintln(out, "Local manifest: missing")
} else {
localManifest = m
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
writeStageStatuses(out, m)
}
@@ -72,7 +75,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
lockChecks := inspectEffectiveLocks(ctx, cfg, store)
locks := lockChecks.Locks
lockErr := lockChecks.Err
if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil {
if catalog, catalogErr := buildHelperArtifactCatalog(cfg, localManifest); catalogErr != nil {
fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr)
} else if storeErr == nil {
catalogLocks := locks

View File

@@ -9,8 +9,10 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -62,6 +64,55 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
}
}
func TestExecuteRestoreRoundTripsPublishedExtractionAndManifestMetadata(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"artifacts/encounters.json", []byte(`{"encounters":[]}`))
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
remoteManifest := manifest.New(cfg.Session.SessionID, time.Now().UTC())
remoteManifest.Campaign = cfg.Session.Campaign
remoteManifest.RunID = "20260519T010203Z-a1b2c3d4"
remoteManifest.Stages["extract"] = &manifest.StageRecord{
Name: "extract", Status: manifest.StatusSucceeded,
Outputs: []manifest.ArtifactRecord{{
Kind: "notarius_lane", SourceID: artifacts.ExtractionArtifactSourceID("encounters"),
LocalPath: "/prior/workspace/artifacts/notarius/extract-run-1/lanes/encounters.json",
Contract: &artifactmodel.ContractMetadata{
MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1",
},
ExternalProvenance: &artifactmodel.ExternalProvenance{
System: "notarius", RunID: "notarius-run-1", PipelineID: "campaign.extract", ArtifactID: "encounters",
},
}},
}
manifestBody, err := json.Marshal(remoteManifest)
if err != nil {
t.Fatal(err)
}
seedRestoreObject(fake, manifestKey, manifestBody)
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "encounters.json"), `{"encounters":[]}`)
restored, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(sessionRoot, "manifest.json"))
if err != nil {
t.Fatalf("load restored manifest: %v", err)
}
lane := restored.Stages["extract"].Outputs[0]
if lane.Contract == nil || lane.Contract.SchemaID != "encounters" || lane.ExternalProvenance == nil || lane.ExternalProvenance.RunID != "notarius-run-1" {
t.Fatalf("restored extraction metadata = %#v", lane)
}
}
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -311,7 +311,17 @@ func DeriveDefaultPublishedDestination(source Source, configured map[string]stri
// ResolvePublishedDestination validates and normalizes an explicit destination,
// or derives one when omitted.
func ResolvePublishedDestination(sourceID, explicitDest string, configured map[string]string) (string, error) {
source, err := ValidatePublishSource(sourceID, configured)
return ResolvePublishedDestinationWithExtractions(sourceID, explicitDest, configured, nil)
}
// ResolvePublishedDestinationWithExtractions validates and normalizes a destination
// while accepting extraction sources declared by the effective Notarius configuration.
func ResolvePublishedDestinationWithExtractions(
sourceID, explicitDest string,
configured map[string]string,
extractions map[string]struct{},
) (string, error) {
source, err := ValidatePublishSourceWithExtractions(sourceID, configured, extractions)
if err != nil {
return "", err
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
const (
@@ -30,6 +31,25 @@ type ExtractionArtifactDefinition struct {
ModuleKey string
}
// ExtractionDefinitionsFromConfig converts the effective Notarius output map into catalog definitions.
func ExtractionDefinitionsFromConfig(cfg *config.NotariusConfig) map[string]ExtractionArtifactDefinition {
if cfg == nil || len(cfg.Outputs) == 0 {
return nil
}
definitions := make(map[string]ExtractionArtifactDefinition, len(cfg.Outputs))
for key, output := range cfg.Outputs {
definitions[key] = ExtractionArtifactDefinition{
LaneID: output.LaneID,
PipelineID: cfg.PipelineID,
MediaType: output.MediaType,
SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion,
ModuleKey: output.ModuleKey,
}
}
return definitions
}
// CatalogEntry is one runtime catalog entry resolved by source ID.
type CatalogEntry struct {
SourceID string

View File

@@ -172,7 +172,7 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notariu
}
dest := strings.TrimSpace(item.Dest)
if dest == "" {
derivedDest, err := artifactpolicy.ResolvePublishedDestination(source, "", configuredOutputs)
derivedDest, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(source, "", configuredOutputs, extractionOutputs)
if err != nil {
return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err)
}

View File

@@ -752,11 +752,13 @@ func buildAnalyzeRuntimeArtifactCatalog(
if err := catalog.RegisterBuiltIns(); err != nil {
return nil, err
}
extractionDefinitions := configuredExtractionDefinitions(notariusCfg)
extractionDefinitions := artifacts.ExtractionDefinitionsFromConfig(notariusCfg)
if err := catalog.RegisterExtractionArtifacts(extractionDefinitions); err != nil {
return nil, err
}
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
if scriptoriumCfg == nil {
return catalog, nil
}

View File

@@ -124,7 +124,12 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("publish: collect previous files: %w", err)
}
runtimeCatalog, err := buildPublishRuntimeArtifactCatalog(sessionPaths, env.Config.Pipeline.Scriptorium)
runtimeCatalog, err := buildPublishRuntimeArtifactCatalog(
sessionPaths,
m,
env.Config.Pipeline.Scriptorium,
env.Config.Pipeline.Notarius,
)
if err != nil {
return nil, fmt.Errorf("publish: build runtime artifact catalog: %w", err)
}
@@ -361,10 +366,11 @@ func resolvePublishOutputs(
lockSet := publishLockSet(locks)
selectedSet := publishSelectedArtifactSet(selectedArtifactKeys)
configuredOutputs := configuredOutputPathMapFromCatalog(catalog)
extractionOutputs := extractionOutputSetFromCatalog(catalog)
for _, rule := range rules {
source := strings.TrimSpace(rule.Source)
required := rule.Required == nil || *rule.Required
dest, err := resolvePublishOutputDest(rule, configuredOutputs)
dest, err := resolvePublishOutputDest(rule, configuredOutputs, extractionOutputs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("source %q: %w", source, err)
}
@@ -454,8 +460,8 @@ func publishLockSet(locks []config.PublishLockRule) map[string]config.PublishLoc
return out
}
func resolvePublishOutputDest(rule config.PublishOutputRule, configured map[string]string) (string, error) {
return artifactpolicy.ResolvePublishedDestination(rule.Source, rule.Dest, configured)
func resolvePublishOutputDest(rule config.PublishOutputRule, configured map[string]string, extractions map[string]struct{}) (string, error) {
return artifactpolicy.ResolvePublishedDestinationWithExtractions(rule.Source, rule.Dest, configured, extractions)
}
func configuredOutputPathMapFromCatalog(catalog *artifacts.ArtifactCatalog) map[string]string {
@@ -472,14 +478,36 @@ func configuredOutputPathMapFromCatalog(catalog *artifacts.ArtifactCatalog) map[
return out
}
func extractionOutputSetFromCatalog(catalog *artifacts.ArtifactCatalog) map[string]struct{} {
out := map[string]struct{}{}
if catalog == nil {
return out
}
for _, entry := range catalog.ListExtraction() {
if strings.TrimSpace(entry.ExtractionKey) != "" {
out[entry.ExtractionKey] = struct{}{}
}
}
return out
}
func buildPublishRuntimeArtifactCatalog(
paths artifacts.SessionPaths,
m *manifest.Manifest,
scriptoriumCfg *config.ScriptoriumConfig,
notariusCfg *config.NotariusConfig,
) (*artifacts.ArtifactCatalog, error) {
catalog := artifacts.NewArtifactCatalog()
if err := catalog.RegisterBuiltIns(); err != nil {
return nil, err
}
extractionDefinitions := artifacts.ExtractionDefinitionsFromConfig(notariusCfg)
if err := catalog.RegisterExtractionArtifacts(extractionDefinitions); err != nil {
return nil, err
}
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
if scriptoriumCfg == nil {
return catalog, nil
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -198,6 +199,119 @@ func TestPublishUsesCustomOutputRules(t *testing.T) {
}
}
func TestPublishUploadsExplicitExtractionAndPreservesManifestMetadata(t *testing.T) {
env, m, _ := publishFixture(t)
lanePath := configurePublishExtractionFixture(t, env, m)
env.SelectedArtifactKeys = []string{"session_recap"}
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: artifacts.ExtractionArtifactSourceID("encounters"), Dest: "artifacts/encounters.json", Required: boolPtr(true)},
}
result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
fake := env.ObjectStore.(*storage.FakeBackend)
publishedKey := m.S3SessionPrefix + "artifacts/encounters.json"
if got := string(fake.Objects[publishedKey].Data); got != `{"encounters":[]}` {
t.Fatalf("published extraction = %q", got)
}
if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/notarius/index.json"]; ok {
t.Fatal("Notarius index was implicitly published")
}
for key := range fake.Objects {
if strings.Contains(key, "/notarius/") {
t.Fatalf("Notarius bundle member was implicitly published at %q", key)
}
}
if result.Metadata["published_files_uploaded"] != 1 {
t.Fatalf("published_files_uploaded = %#v, want 1", result.Metadata["published_files_uploaded"])
}
if uploads := fake.Uploads; len(uploads) == 0 || uploads[len(uploads)-1].Key != m.S3SessionPrefix+"current/run_id.txt" {
t.Fatalf("last upload = %#v, want current run pointer", uploads)
}
var current manifest.Manifest
if err := json.Unmarshal(fake.Objects[m.S3SessionPrefix+"current/manifest.json"].Data, &current); err != nil {
t.Fatalf("unmarshal current manifest: %v", err)
}
extractRecord := current.Stages["extract"]
if extractRecord == nil || len(extractRecord.Outputs) != 2 {
t.Fatalf("extract record = %#v", extractRecord)
}
lane := extractRecord.Outputs[0]
if lane.LocalPath != lanePath || lane.Contract == nil || lane.Contract.SchemaID != "encounters" ||
lane.ExternalProvenance == nil || lane.ExternalProvenance.RunID != "notarius-run-1" {
t.Fatalf("serialized extraction metadata = %#v", lane)
}
}
func TestPublishRequiredInvalidExtractionFails(t *testing.T) {
env, m, _ := publishFixture(t)
lanePath := configurePublishExtractionFixture(t, env, m)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: artifacts.ExtractionArtifactSourceID("encounters"), Dest: "artifacts/encounters.json", Required: boolPtr(true)},
}
writeStageTestFile(t, lanePath, `{"tampered":true}`)
_, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.extraction.encounters"`) {
t.Fatalf("Run() error = %v, want unavailable extraction failure", err)
}
if len(env.ObjectStore.(*storage.FakeBackend).Uploads) != 0 {
t.Fatal("publish uploaded files after extraction validation failed")
}
}
func TestPublishDisabledExtractionConfigurationIsUnavailable(t *testing.T) {
env, m, _ := publishFixture(t)
configurePublishExtractionFixture(t, env, m)
env.Config.Pipeline.Notarius.Enabled = false
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: artifacts.ExtractionArtifactSourceID("encounters"), Dest: "artifacts/encounters.json", Required: boolPtr(true)},
}
_, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.extraction.encounters"`) {
t.Fatalf("Run() error = %v, want unavailable disabled extraction", err)
}
}
func TestPublishOptionalMissingExtractionIsSkipped(t *testing.T) {
env, m, _ := publishFixture(t)
lanePath := configurePublishExtractionFixture(t, env, m)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: artifacts.ExtractionArtifactSourceID("encounters"), Dest: "artifacts/encounters.json", Required: boolPtr(false)},
}
if err := os.Remove(lanePath); err != nil {
t.Fatal(err)
}
result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if got := result.Metadata["skipped_optional_outputs"].([]string); !reflect.DeepEqual(got, []string{"artifacts/encounters.json"}) {
t.Fatalf("skipped_optional_outputs = %#v", got)
}
}
func TestPublishArtifactSelectionDoesNotFilterExtractionOutputs(t *testing.T) {
env, m, _ := publishFixture(t)
configurePublishExtractionFixture(t, env, m)
env.SelectedArtifactKeys = []string{"session_recap"}
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: artifacts.ExtractionArtifactSourceID("encounters"), Dest: "artifacts/encounters.json", Required: boolPtr(true)},
}
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err)
}
if _, ok := env.ObjectStore.(*storage.FakeBackend).Objects[m.S3SessionPrefix+"artifacts/encounters.json"]; !ok {
t.Fatal("explicit extraction output was filtered by Scriptorium artifact selection")
}
}
func TestPublishSkipsOptionalMissingOutput(t *testing.T) {
env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
@@ -591,6 +705,51 @@ func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
return env, m, runRoot
}
func configurePublishExtractionFixture(t *testing.T, env *Env, m *manifest.Manifest) string {
t.Helper()
env.Config.Pipeline.Notarius = &config.NotariusConfig{
Enabled: true, PipelineID: "campaign.extract",
Outputs: map[string]config.NotariusOutputConfig{
"encounters": {
LaneID: "encounters", MediaType: "application/json", SchemaID: "encounters",
SchemaVersion: "1", ModuleKey: "encounters",
},
},
}
paths := publishSessionPaths(env, m)
producerRunID := "extract-run-1"
bundleRoot := filepath.Join(paths.ArtifactsDir, "notarius", producerRunID)
lanePath := filepath.Join(bundleRoot, "lanes", "encounters.json")
indexPath := filepath.Join(bundleRoot, "index.json")
writeStageTestFile(t, lanePath, `{"encounters":[]}`)
writeStageTestFile(t, indexPath, `{"lanes":[]}`)
laneChecksum, err := artifacts.SHA256File(lanePath)
if err != nil {
t.Fatal(err)
}
indexChecksum, err := artifacts.SHA256File(indexPath)
if err != nil {
t.Fatal(err)
}
m.Stages["extract"] = &manifest.StageRecord{
Name: "extract", Status: manifest.StatusSucceeded,
Metadata: map[string]any{
"narratio_run_id": producerRunID, "bundle_root": bundleRoot,
"receipt": map[string]any{"run_id": "notarius-run-1", "pipeline_id": "campaign.extract"},
},
Outputs: []manifest.ArtifactRecord{
{
Kind: "notarius_lane", SourceID: artifacts.ExtractionArtifactSourceID("encounters"), LocalPath: lanePath,
ProducerRunID: producerRunID, Checksum: laneChecksum,
Contract: &artifactmodel.ContractMetadata{MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters"},
ExternalProvenance: &artifactmodel.ExternalProvenance{System: "notarius", RunID: "notarius-run-1", PipelineID: "campaign.extract", ArtifactID: "encounters"},
},
{Kind: "notarius_index", LocalPath: indexPath, ProducerRunID: producerRunID, Checksum: indexChecksum},
},
}
return lanePath
}
type publishedOutputFailingStore struct {
delegate *storage.FakeBackend
failKey string

View File

@@ -1,24 +0,0 @@
package stage
import (
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func configuredExtractionDefinitions(cfg *config.NotariusConfig) map[string]artifacts.ExtractionArtifactDefinition {
if cfg == nil || !cfg.Enabled || len(cfg.Outputs) == 0 {
return nil
}
definitions := make(map[string]artifacts.ExtractionArtifactDefinition, len(cfg.Outputs))
for key, output := range cfg.Outputs {
definitions[key] = artifacts.ExtractionArtifactDefinition{
LaneID: output.LaneID,
PipelineID: cfg.PipelineID,
MediaType: output.MediaType,
SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion,
ModuleKey: output.ModuleKey,
}
}
return definitions
}