From 7d584ee6cde2881879fb6ca4bd4007c67d0b6efd Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 23 May 2026 13:28:25 +0000 Subject: [PATCH] Centralize artifact source and publish destination policy --- internal/app/operator_helpers.go | 31 ++--- internal/artifactpolicy/policy.go | 145 ++++++++++++++++++++++++ internal/artifactpolicy/policy_test.go | 92 +++++++++++++++ internal/artifacts/artifact_resolver.go | 36 +++--- internal/artifacts/catalog.go | 4 +- internal/config/validate.go | 66 +++-------- internal/previouscache/previouscache.go | 3 +- internal/stage/analyze.go | 23 ++-- internal/stage/archive.go | 34 +++--- 9 files changed, 319 insertions(+), 115 deletions(-) create mode 100644 internal/artifactpolicy/policy.go create mode 100644 internal/artifactpolicy/policy_test.go diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 9c8a7db..19b3843 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -12,10 +12,10 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "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" - "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" "gopkg.in/yaml.v3" ) @@ -999,7 +999,7 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art } fmt.Fprintln(out, "Previous-session:") for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) { - fmt.Fprintf(out, "- narratio.previous_session.artifact.%s required=%t\n", req.Name, req.Required) + fmt.Fprintf(out, "- %s required=%t\n", artifactpolicy.PreviousSessionSourceID(req.Name), req.Required) } fmt.Fprintln(out, "Published:") for _, rule := range cfg.Pipeline.Publish.Outputs { @@ -1060,18 +1060,7 @@ func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config, func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) { source := strings.TrimSpace(rule.Source) - dest := strings.TrimSpace(rule.Dest) - if dest == "" { - entry, ok := catalog.Lookup(source) - if !ok { - return "", false, fmt.Errorf("destination omitted and source is unknown") - } - dest = strings.TrimSpace(entry.CanonicalRelPath) - if dest == "" { - return "", false, fmt.Errorf("destination omitted and no canonical destination is available") - } - } - normalized, err := pathsafe.NormalizeRelativeDestination(dest) + normalized, err := artifactpolicy.ResolvePublishedDestination(source, rule.Dest, helperConfiguredOutputPathMap(catalog)) if err != nil { return "", false, err } @@ -1080,6 +1069,20 @@ func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts return normalized, showDest, nil } +func helperConfiguredOutputPathMap(catalog *artifacts.ArtifactCatalog) map[string]string { + out := map[string]string{} + if catalog == nil { + return out + } + for _, entry := range catalog.ListConfigured() { + if strings.TrimSpace(entry.ConfiguredKey) == "" { + continue + } + out[entry.ConfiguredKey] = strings.TrimSpace(entry.CanonicalRelPath) + } + return out +} + func publishedOutputRemoteStateKey(source, dest string) string { return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest) } diff --git a/internal/artifactpolicy/policy.go b/internal/artifactpolicy/policy.go new file mode 100644 index 0000000..ab18760 --- /dev/null +++ b/internal/artifactpolicy/policy.go @@ -0,0 +1,145 @@ +package artifactpolicy + +import ( + "fmt" + "regexp" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" +) + +const ( + SourceBoundsSession = "narratio.bounds.session" + + configuredSourcePrefix = "narratio.artifact." + previousConfiguredSrcPrefix = "narratio.previous_session.artifact." +) + +var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) +var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) + +type SourceKind string + +const ( + SourceKindBuiltIn SourceKind = "built_in" + SourceKindConfiguredArtifact SourceKind = "configured_artifact" + SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact" +) + +// Source describes one normalized artifact source identifier. +type Source struct { + ID string + Kind SourceKind + ConfiguredKey string +} + +// ConfiguredSourceID converts a configured artifact key into source id form. +func ConfiguredSourceID(key string) string { + return configuredSourcePrefix + strings.TrimSpace(key) +} + +// PreviousSessionSourceID converts a configured artifact key into previous-session source id form. +func PreviousSessionSourceID(key string) string { + return previousConfiguredSrcPrefix + strings.TrimSpace(key) +} + +// ParseConfiguredSource extracts configured key from narratio.artifact.. +func ParseConfiguredSource(source string) (string, bool) { + matches := configuredSourceRE.FindStringSubmatch(strings.TrimSpace(source)) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + +// ParsePreviousSessionSource extracts configured key from narratio.previous_session.artifact.. +func ParsePreviousSessionSource(source string) (string, bool) { + matches := previousSourceRE.FindStringSubmatch(strings.TrimSpace(source)) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + +// ClassifySource classifies a source id as built-in, configured, or previous-session configured. +func ClassifySource(source string) (Source, error) { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return Source{}, fmt.Errorf("artifact source is required") + } + if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok { + return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil + } + if trimmed == SourceBoundsSession { + return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil + } + if key, ok := ParseConfiguredSource(trimmed); ok { + return Source{ID: trimmed, Kind: SourceKindConfiguredArtifact, ConfiguredKey: key}, nil + } + if key, ok := ParsePreviousSessionSource(trimmed); ok { + return Source{ID: trimmed, Kind: SourceKindPreviousArtifact, ConfiguredKey: key}, nil + } + return Source{}, fmt.Errorf("unsupported artifact source %q", source) +} + +// ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact. +func ValidatePublishSource(source string, configured map[string]string) (Source, error) { + classified, err := ClassifySource(source) + if err != nil { + return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.") + } + if classified.Kind == SourceKindPreviousArtifact { + return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.") + } + if classified.Kind == SourceKindConfiguredArtifact { + if configured == nil { + return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) + } + if _, ok := configured[classified.ConfiguredKey]; !ok { + return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) + } + } + return classified, nil +} + +// DeriveDefaultPublishedDestination returns the default publish destination for one source. +func DeriveDefaultPublishedDestination(source Source, configured map[string]string) (string, error) { + switch source.Kind { + case SourceKindBuiltIn: + if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source.ID); ok { + return pathsafe.NormalizeRelativeDestination(spec.CanonicalRelPath) + } + if source.ID == SourceBoundsSession { + return pathsafe.NormalizeRelativeDestination("artifacts/session_bounds.json") + } + return "", fmt.Errorf("unsupported built-in source %q", source.ID) + case SourceKindConfiguredArtifact: + if configured == nil { + return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) + } + outputPath, ok := configured[source.ConfiguredKey] + if !ok { + return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) + } + if strings.TrimSpace(outputPath) == "" { + return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", source.ConfiguredKey) + } + return pathsafe.NormalizeRelativeDestination(outputPath) + default: + return "", fmt.Errorf("publish destination cannot be derived from source %q", source.ID) + } +} + +// 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) + if err != nil { + return "", err + } + if strings.TrimSpace(explicitDest) != "" { + return pathsafe.NormalizeRelativeDestination(explicitDest) + } + return DeriveDefaultPublishedDestination(source, configured) +} diff --git a/internal/artifactpolicy/policy_test.go b/internal/artifactpolicy/policy_test.go new file mode 100644 index 0000000..bd909fe --- /dev/null +++ b/internal/artifactpolicy/policy_test.go @@ -0,0 +1,92 @@ +package artifactpolicy + +import ( + "strings" + "testing" +) + +func TestClassifySource(t *testing.T) { + tests := []struct { + name string + source string + wantKind SourceKind + wantKey string + wantErrLike string + }{ + {name: "built in transcript", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn}, + {name: "built in bounds", source: "narratio.bounds.session", wantKind: SourceKindBuiltIn}, + {name: "configured artifact", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"}, + {name: "previous session configured", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap"}, + {name: "unsupported", source: "narratio.unknown", wantErrLike: "unsupported artifact source"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ClassifySource(tt.source) + if tt.wantErrLike != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrLike) { + t.Fatalf("ClassifySource() error = %v, want like %q", err, tt.wantErrLike) + } + return + } + if err != nil { + t.Fatalf("ClassifySource() error = %v", err) + } + if got.Kind != tt.wantKind { + t.Fatalf("ClassifySource().Kind = %q, want %q", got.Kind, tt.wantKind) + } + if got.ConfiguredKey != tt.wantKey { + t.Fatalf("ClassifySource().ConfiguredKey = %q, want %q", got.ConfiguredKey, tt.wantKey) + } + }) + } +} + +func TestValidatePublishSource(t *testing.T) { + configured := map[string]string{"session_recap": "artifacts/session_recap.md"} + if _, err := ValidatePublishSource("narratio.artifact.session_recap", configured); err != nil { + t.Fatalf("ValidatePublishSource(configured) error = %v", err) + } + if _, err := ValidatePublishSource("narratio.previous_session.artifact.session_recap", configured); err == nil { + t.Fatal("ValidatePublishSource(previous) error = nil, want error") + } + if _, err := ValidatePublishSource("narratio.artifact.missing", configured); err == nil { + t.Fatal("ValidatePublishSource(missing configured) error = nil, want error") + } +} + +func TestResolvePublishedDestination(t *testing.T) { + configured := map[string]string{"session_recap": "artifacts/session_recap.md"} + + got, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "", configured) + if err != nil { + t.Fatalf("ResolvePublishedDestination(built-in) error = %v", err) + } + if got != "transcripts/final.trimmed.json" { + t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got) + } + + got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured) + if err != nil { + t.Fatalf("ResolvePublishedDestination(configured) error = %v", err) + } + if got != "artifacts/session_recap.md" { + t.Fatalf("configured destination = %q, want artifacts/session_recap.md", got) + } + + got, err = ResolvePublishedDestination("narratio.transcript.final_trimmed", "published/../published/final.json", configured) + if err != nil { + t.Fatalf("ResolvePublishedDestination(explicit) error = %v", err) + } + if got != "published/final.json" { + t.Fatalf("explicit destination = %q, want published/final.json", got) + } +} + +func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) { + configured := map[string]string{"session_recap": "artifacts/session_recap.md"} + _, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "../escape.txt", configured) + if err == nil { + t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection") + } +} diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index 96e49e9..1e7008b 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -6,10 +6,10 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -40,8 +40,6 @@ const ( // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. var ErrSessionArtifactNotFound = errors.New("session artifact not found") -var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) -var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) type artifactContentKind string @@ -107,43 +105,39 @@ func (e *SessionArtifactNotFoundError) Unwrap() error { // NormalizeSessionArtifactSource validates canonical artifact IDs. func NormalizeSessionArtifactSource(source string) (string, error) { - normalized := strings.TrimSpace(source) - if normalized == "" { - return "", fmt.Errorf("artifact source is required") - } - if _, ok := artifactRegistry[normalized]; !ok { + classified, err := artifactpolicy.ClassifySource(source) + if err != nil { return "", fmt.Errorf("unsupported artifact source %q", source) } - return normalized, nil + if classified.Kind != artifactpolicy.SourceKindBuiltIn { + return "", fmt.Errorf("unsupported artifact source %q", source) + } + if _, ok := artifactRegistry[classified.ID]; !ok { + return "", fmt.Errorf("unsupported artifact source %q", source) + } + return classified.ID, nil } // IsConfiguredArtifactSource returns true when source is narratio.artifact.. func IsConfiguredArtifactSource(source string) bool { - return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source)) + _, ok := artifactpolicy.ParseConfiguredSource(source) + return ok } // ConfiguredArtifactName extracts from narratio.artifact.. func ConfiguredArtifactName(source string) (string, bool) { - matches := configuredArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source)) - if len(matches) != 2 { - return "", false - } - return matches[1], true + return artifactpolicy.ParseConfiguredSource(source) } // IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.. func IsPreviousSessionArtifactSource(source string) bool { - _, ok := PreviousSessionArtifactName(source) + _, ok := artifactpolicy.ParsePreviousSessionSource(source) return ok } // PreviousSessionArtifactName extracts from narratio.previous_session.artifact.. func PreviousSessionArtifactName(source string) (string, bool) { - matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source)) - if len(matches) != 2 { - return "", false - } - return matches[1], true + return artifactpolicy.ParsePreviousSessionSource(source) } // ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. diff --git a/internal/artifacts/catalog.go b/internal/artifacts/catalog.go index 6f06538..d3412bc 100644 --- a/internal/artifacts/catalog.go +++ b/internal/artifacts/catalog.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" ) const ( @@ -47,7 +49,7 @@ func NewArtifactCatalog() *ArtifactCatalog { // ConfiguredArtifactSourceID converts a configured artifact key into canonical source ID. func ConfiguredArtifactSourceID(key string) string { - return "narratio.artifact." + strings.TrimSpace(key) + return artifactpolicy.ConfiguredSourceID(key) } // RegisterBuiltIns registers built-in source definitions used by runtime artifact resolution. diff --git a/internal/config/validate.go b/internal/config/validate.go index db52677..6a3cbd2 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -10,6 +10,7 @@ import ( "time" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) @@ -135,6 +136,7 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error { if cfg == nil { return nil } + configuredOutputs := scriptoriumOutputPathMap(scriptorium) seenDest := map[string]struct{}{} for i, item := range cfg.Outputs { prefix := fmt.Sprintf("pipeline.publish.outputs[%d]", i) @@ -142,12 +144,12 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error { if source == "" { return fmt.Errorf("%s.source is required", prefix) } - if _, err := publishSourceKnown(source, scriptorium); err != nil { + if _, err := artifactpolicy.ValidatePublishSource(source, configuredOutputs); err != nil { return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err) } dest := strings.TrimSpace(item.Dest) if dest == "" { - derivedDest, err := derivePublishOutputDest(source, scriptorium) + derivedDest, err := artifactpolicy.ResolvePublishedDestination(source, "", configuredOutputs) if err != nil { return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err) } @@ -182,6 +184,7 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error { func ValidatePublishLockRules(locks []PublishLockRule, scriptorium *ScriptoriumConfig, label string) ([]PublishLockRule, error) { seenLocks := map[string]struct{}{} out := make([]PublishLockRule, 0, len(locks)) + configuredOutputs := scriptoriumOutputPathMap(scriptorium) if strings.TrimSpace(label) == "" { label = "publish.locks" } @@ -191,7 +194,7 @@ func ValidatePublishLockRules(locks []PublishLockRule, scriptorium *ScriptoriumC if source == "" { return nil, fmt.Errorf("%s.source is required", prefix) } - if _, err := publishSourceKnown(source, scriptorium); err != nil { + if _, err := artifactpolicy.ValidatePublishSource(source, configuredOutputs); err != nil { return nil, fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err) } if _, ok := seenLocks[source]; ok { @@ -233,48 +236,15 @@ func MergePublishLockRules(staticLocks, remoteLocks []PublishLockRule) []Publish return out } -func publishSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) { - trimmed := strings.TrimSpace(source) - if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok { - return "", nil - } - switch trimmed { - case "narratio.bounds.session": - return "", nil - } - matches := narratioArtifactSourceRE.FindStringSubmatch(trimmed) - if len(matches) != 2 { - return "", fmt.Errorf("must be a built-in source id or narratio.artifact.") - } - artifactKey := matches[1] +func scriptoriumOutputPathMap(scriptorium *ScriptoriumConfig) map[string]string { + out := map[string]string{} if scriptorium == nil || len(scriptorium.Artifacts) == 0 { - return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey) + return out } - if _, ok := scriptorium.Artifacts[artifactKey]; !ok { - return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey) + for key, artifactCfg := range scriptorium.Artifacts { + out[strings.TrimSpace(key)] = strings.TrimSpace(artifactCfg.OutputPath) } - return artifactKey, nil -} - -func derivePublishOutputDest(source string, scriptorium *ScriptoriumConfig) (string, error) { - trimmed := strings.TrimSpace(source) - if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok { - return spec.CanonicalRelPath, nil - } - switch trimmed { - case "narratio.bounds.session": - return filepath.ToSlash(filepath.Join(PathArtifactsDirSegment, "session_bounds.json")), nil - } - artifactKey, err := publishSourceKnown(trimmed, scriptorium) - if err != nil { - return "", err - } - artifactCfg := scriptorium.Artifacts[artifactKey] - outputPath := strings.TrimSpace(artifactCfg.OutputPath) - if outputPath == "" { - return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", artifactKey) - } - return outputPath, nil + return out } func validateSecrets(cfg *SecretsConfig) error { @@ -666,8 +636,6 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`) var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) -var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) -var narratioPreviousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) { trimmedSource := strings.TrimSpace(source) @@ -676,8 +644,8 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf } if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") { - matches := narratioPreviousSessionArtifactSourceRE.FindStringSubmatch(trimmedSource) - if len(matches) != 2 { + referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource) + if !ok { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$", artifactName, @@ -685,7 +653,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf source, ) } - referenced := matches[1] if _, ok := configuredArtifacts[referenced]; !ok { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", @@ -698,8 +665,8 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf return "", nil } - matches := narratioArtifactSourceRE.FindStringSubmatch(trimmedSource) - if len(matches) != 2 { + referenced, ok := artifactpolicy.ParseConfiguredSource(trimmedSource) + if !ok { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, @@ -707,7 +674,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf source, ) } - referenced := matches[1] if _, ok := configuredArtifacts[referenced]; !ok { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", diff --git a/internal/previouscache/previouscache.go b/internal/previouscache/previouscache.go index 1d71971..0e1a733 100644 --- a/internal/previouscache/previouscache.go +++ b/internal/previouscache/previouscache.go @@ -10,6 +10,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "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" @@ -286,7 +287,7 @@ func artifactRelativePathCandidates( candidates = append(candidates, normalized) } - sourceID := artifacts.ConfiguredArtifactSourceID(artifactName) + sourceID := artifactpolicy.ConfiguredSourceID(artifactName) if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok { appendCandidate(rel) base := path.Base(rel) diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index fd65b73..984fb7d 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -12,6 +12,7 @@ import ( "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" + "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" @@ -549,12 +550,8 @@ func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string { } func configuredArtifactNameFromSourceID(sourceID string) string { - trimmed := strings.TrimSpace(sourceID) - const prefix = "narratio.artifact." - if !strings.HasPrefix(trimmed, prefix) { - return "" - } - return strings.TrimPrefix(trimmed, prefix) + name, _ := artifactpolicy.ParseConfiguredSource(sourceID) + return name } func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { @@ -631,7 +628,11 @@ func resolveScriptoriumInput( runtimeCatalog *artifacts.ArtifactCatalog, ) (string, bool, *artifacts.ResolvedSessionArtifact, error) { source := strings.TrimSpace(inputCfg.Source) - if artifacts.IsPreviousSessionArtifactSource(source) { + classified, classifyErr := artifactpolicy.ClassifySource(source) + if classifyErr != nil { + return "", false, nil, classifyErr + } + if classified.Kind == artifactpolicy.SourceKindPreviousArtifact { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) if err == nil { copy := resolved @@ -656,17 +657,13 @@ func resolveScriptoriumInput( return resolved.Path, true, ©, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { - if artifacts.IsConfiguredArtifactSource(source) { + if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact { if inputCfg.Required { return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) } return "", false, nil, nil } - normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(source) - if normalizeErr != nil { - return "", false, nil, normalizeErr - } - switch normalized { + switch classified.ID { case artifacts.ArtifactTranscriptPolished: return "", false, nil, nil case artifacts.ArtifactTranscriptFinal: diff --git a/internal/stage/archive.go b/internal/stage/archive.go index f5a215f..c0e432b 100644 --- a/internal/stage/archive.go +++ b/internal/stage/archive.go @@ -13,10 +13,10 @@ import ( "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "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" - "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) type archiveStage struct{} @@ -359,15 +359,16 @@ func resolvePublishOutputs( lockedOutputs := make([]publishLockedOutput, 0) lockSet := archiveLockSet(locks) selectedSet := archiveSelectedArtifactSet(selectedArtifactKeys) + configuredOutputs := configuredOutputPathMapFromCatalog(catalog) for _, rule := range rules { source := strings.TrimSpace(rule.Source) required := rule.Required == nil || *rule.Required - dest, err := resolvePublishOutputDest(rule, catalog) + dest, err := resolvePublishOutputDest(rule, configuredOutputs) if err != nil { return nil, nil, nil, nil, fmt.Errorf("source %q: %w", source, err) } if len(selectedSet) > 0 { - if key, ok := artifacts.ConfiguredArtifactName(source); ok { + if key, ok := artifactpolicy.ParseConfiguredSource(source); ok { if _, selected := selectedSet[key]; !selected { skippedUnselectedOutputs = append(skippedUnselectedOutputs, publishSkippedUnselectedOutput{ Source: source, @@ -452,19 +453,22 @@ func archiveLockSet(locks []config.PublishLockRule) map[string]config.PublishLoc return out } -func resolvePublishOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, error) { - dest := strings.TrimSpace(rule.Dest) - if dest == "" { - entry, ok := catalog.Lookup(strings.TrimSpace(rule.Source)) - if !ok { - return "", fmt.Errorf("destination omitted and source is unknown") - } - dest = strings.TrimSpace(entry.CanonicalRelPath) - if dest == "" { - return "", fmt.Errorf("destination omitted and no canonical destination is available") - } +func resolvePublishOutputDest(rule config.PublishOutputRule, configured map[string]string) (string, error) { + return artifactpolicy.ResolvePublishedDestination(rule.Source, rule.Dest, configured) +} + +func configuredOutputPathMapFromCatalog(catalog *artifacts.ArtifactCatalog) map[string]string { + out := map[string]string{} + if catalog == nil { + return out } - return pathsafe.NormalizeRelativeDestination(dest) + for _, entry := range catalog.ListConfigured() { + if strings.TrimSpace(entry.ConfiguredKey) == "" { + continue + } + out[entry.ConfiguredKey] = strings.TrimSpace(entry.CanonicalRelPath) + } + return out } func buildArchiveRuntimeArtifactCatalog(