package artifactpolicy import ( "errors" "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_]*)$`) var ( ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source") ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format") ) 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 } // ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source. type ScriptoriumInputSourceDescriptor struct { Source Source PreviousSession *PreviousSessionSourceDescriptor } // PreviousSessionSourceDescriptor describes one canonical previous-session input source. type PreviousSessionSourceDescriptor struct { SourceID string ConfiguredKey string ConfiguredSourceID string } // UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key. type UnknownConfiguredArtifactError struct { ConfiguredKey string } func (e *UnknownConfiguredArtifactError) Error() string { return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey) } // 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) } // DescribeScriptoriumInputSource classifies one input source and returns descriptor // metadata used by config validation, analyze input resolution, and previous-cache planning. func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) { trimmed := strings.TrimSpace(source) if trimmed == "" { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { descriptor, err := DescribePreviousSessionSource(trimmed) if err != nil { return ScriptoriumInputSourceDescriptor{}, err } return ScriptoriumInputSourceDescriptor{ Source: Source{ ID: descriptor.SourceID, Kind: SourceKindPreviousArtifact, ConfiguredKey: descriptor.ConfiguredKey, }, PreviousSession: &descriptor, }, nil } classified, err := ClassifySource(trimmed) if err != nil { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } return ScriptoriumInputSourceDescriptor{Source: classified}, nil } // DescribePreviousSessionSource validates a canonical previous-session source id // and returns both previous and configured-source vocabulary descriptors. func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) { configuredKey, ok := ParsePreviousSessionSource(source) if !ok { return PreviousSessionSourceDescriptor{}, ErrInvalidPreviousSessionSource } return PreviousSessionSourceDescriptor{ SourceID: PreviousSessionSourceID(configuredKey), ConfiguredKey: configuredKey, ConfiguredSourceID: ConfiguredSourceID(configuredKey), }, nil } // PreviousSessionSourceDescriptorForConfiguredKey derives a previous-session source descriptor // from a configured artifact key. func PreviousSessionSourceDescriptorForConfiguredKey(configuredKey string) (PreviousSessionSourceDescriptor, error) { return DescribePreviousSessionSource(PreviousSessionSourceID(configuredKey)) } // ValidateInputConfiguredReference checks that configured/previous-session sources // reference configured artifacts known to the current Scriptorium config. func ValidateInputConfiguredReference( descriptor ScriptoriumInputSourceDescriptor, configured map[string]struct{}, ) error { switch descriptor.Source.Kind { case SourceKindConfiguredArtifact, SourceKindPreviousArtifact: if _, ok := configured[descriptor.Source.ConfiguredKey]; !ok { return &UnknownConfiguredArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey} } } return nil } // 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) }