package config import ( "errors" "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" ) func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error { if cfg == nil { return nil } if strings.TrimSpace(cfg.Binary) == "" { return fmt.Errorf("pipeline.scriptorium.binary is required when pipeline.scriptorium is configured") } if cfg.ConfigPath != "" && strings.TrimSpace(cfg.ConfigPath) == "" { return fmt.Errorf("pipeline.scriptorium.config_path must be non-empty when provided") } if err := validateDuration("pipeline.scriptorium.timeout", cfg.Timeout); err != nil { return err } artifactNames, configuredArtifacts, err := validateScriptoriumArtifactKeys(cfg.Artifacts) if err != nil { return err } if err := validateScriptoriumDeclarations(cfg.Artifacts, artifactNames); err != nil { return err } referencedArtifacts, err := validateScriptoriumDependenciesAndInputs( cfg.Artifacts, artifactNames, configuredArtifacts, notariusOutputKeySet(notarius), ) if err != nil { return err } if err := validateScriptoriumVariables(cfg.Artifacts, artifactNames); err != nil { return err } if err := validateScriptoriumReferencedOutputs(cfg.Artifacts, referencedArtifacts); err != nil { return err } if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil { return err } return nil } func validateScriptoriumArtifactKeys(artifacts map[string]ScriptoriumArtifactConfig) ([]string, map[string]struct{}, error) { names := make([]string, 0, len(artifacts)) for name := range artifacts { names = append(names, name) } sort.Strings(names) configured := make(map[string]struct{}, len(names)) for _, name := range names { if !artifactpolicy.IsConfiguredKey(name) { return nil, nil, fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$") } configured[name] = struct{}{} } return names, configured, nil } func validateScriptoriumDeclarations(artifacts map[string]ScriptoriumArtifactConfig, artifactNames []string) error { outputOwners := make(map[string]string, len(artifactNames)) for _, artifactName := range artifactNames { artifactCfg := artifacts[artifactName] if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName) } if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName) } if strings.TrimSpace(artifactCfg.OutputPath) != "" { pathField := "pipeline.scriptorium.artifacts." + artifactName + ".output_path" if err := validateRelativeSafePath(pathField, artifactCfg.OutputPath); err != nil { return err } if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil { return err } if previous, exists := outputOwners[artifactCfg.OutputPath]; exists { return fmt.Errorf("%s %q duplicates artifact %q", pathField, artifactCfg.OutputPath, previous) } outputOwners[artifactCfg.OutputPath] = artifactName } if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil { return err } } return nil } func validateScriptoriumDependenciesAndInputs( artifacts map[string]ScriptoriumArtifactConfig, artifactNames []string, configuredArtifacts map[string]struct{}, extractionOutputs map[string]struct{}, ) (map[string]struct{}, error) { referencedArtifacts := make(map[string]struct{}) for _, artifactName := range artifactNames { artifactCfg := artifacts[artifactName] depSet := make(map[string]struct{}, len(artifactCfg.DependsOn)) for i, depName := range artifactCfg.DependsOn { trimmedDep := strings.TrimSpace(depName) field := fmt.Sprintf("pipeline.scriptorium.artifacts.%s.depends_on[%d]", artifactName, i) if trimmedDep == "" { return nil, fmt.Errorf("%s must be non-empty", field) } if _, ok := configuredArtifacts[trimmedDep]; !ok { return nil, fmt.Errorf("%s %q is not a configured artifact key", field, depName) } if trimmedDep == artifactName { return nil, fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", artifactName) } depSet[trimmedDep] = struct{}{} referencedArtifacts[trimmedDep] = struct{}{} } inputNames := make([]string, 0, len(artifactCfg.Inputs)) for inputName := range artifactCfg.Inputs { inputNames = append(inputNames, inputName) } sort.Strings(inputNames) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] trimmedInputName := strings.TrimSpace(inputName) if trimmedInputName == "" { return nil, fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName) } source := strings.TrimSpace(inputCfg.Source) if source == "" { return nil, fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName) } referencedArtifact, err := validateScriptoriumInputSource(artifactName, inputName, source, configuredArtifacts, extractionOutputs) if err != nil { return nil, err } if referencedArtifact != "" { if _, ok := depSet[referencedArtifact]; !ok { return nil, fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q requires depends_on entry %q", artifactName, inputName, source, referencedArtifact, ) } referencedArtifacts[referencedArtifact] = struct{}{} } } } return referencedArtifacts, nil } func validateScriptoriumVariables(artifacts map[string]ScriptoriumArtifactConfig, artifactNames []string) error { for _, artifactName := range artifactNames { artifactCfg := artifacts[artifactName] variableNames := make([]string, 0, len(artifactCfg.Vars)) for varName := range artifactCfg.Vars { variableNames = append(variableNames, varName) } sort.Strings(variableNames) for _, varName := range variableNames { varValue := artifactCfg.Vars[varName] if strings.TrimSpace(varName) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars keys must be non-empty", artifactName) } switch varValue.(type) { case bool, string: default: return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars.%s must be a string or boolean", artifactName, varName) } } } return nil } func validateScriptoriumReferencedOutputs(artifacts map[string]ScriptoriumArtifactConfig, referencedArtifacts map[string]struct{}) error { artifactNames := make([]string, 0, len(referencedArtifacts)) for artifactName := range referencedArtifacts { artifactNames = append(artifactNames, artifactName) } sort.Strings(artifactNames) for _, artifactName := range artifactNames { artifactCfg := artifacts[artifactName] if strings.TrimSpace(artifactCfg.OutputPath) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when artifact is referenced", artifactName) } } return nil } func validateScriptoriumInputSource( artifactName, inputName, source string, configuredArtifacts map[string]struct{}, extractionOutputs map[string]struct{}, ) (string, error) { trimmedSource := strings.TrimSpace(source) descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(trimmedSource) if err != nil { if errors.Is(err, artifactpolicy.ErrInvalidPreviousSessionSource) { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$", artifactName, inputName, source, ) } return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, inputName, source, ) } if err := artifactpolicy.ValidateInputReference(descriptor, configuredArtifacts, extractionOutputs); err != nil { var unknownConfigured *artifactpolicy.UnknownConfiguredArtifactError if errors.As(err, &unknownConfigured) { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", artifactName, inputName, source, unknownConfigured.ConfiguredKey, ) } var unknownExtraction *artifactpolicy.UnknownExtractionArtifactError if errors.As(err, &unknownExtraction) { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown extraction output %q", artifactName, inputName, source, unknownExtraction.ConfiguredKey, ) } return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, inputName, source, ) } if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact { return descriptor.Source.ConfiguredKey, nil } return "", nil } // ValidateScriptoriumArtifactDependencies validates the configured dependency // graph independently of execution selection. Disabled artifacts remain valid // prerequisites for explicit selections and therefore participate in cycles. func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArtifactConfig) error { if len(artifacts) == 0 { return nil } nodes := make([]string, 0, len(artifacts)) for name := range artifacts { nodes = append(nodes, name) } sort.Strings(nodes) graph := make(map[string][]string, len(artifacts)) for _, name := range nodes { cfg := artifacts[name] if !artifactpolicy.IsConfiguredKey(name) { return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$") } seen := make(map[string]struct{}, len(cfg.DependsOn)) for _, dep := range cfg.DependsOn { trimmedDep := strings.TrimSpace(dep) if trimmedDep == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on entries must be non-empty", name) } if _, ok := artifacts[trimmedDep]; !ok { return fmt.Errorf("pipeline.scriptorium.artifacts.%s dependency %q is not configured", name, dep) } if trimmedDep == name { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", name) } if _, duplicate := seen[trimmedDep]; duplicate { continue } seen[trimmedDep] = struct{}{} graph[name] = append(graph[name], trimmedDep) } sort.Strings(graph[name]) } visiting := make(map[string]bool, len(artifacts)) visited := make(map[string]bool, len(artifacts)) var visit func(node string) error visit = func(node string) error { if visiting[node] { return fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles") } if visited[node] { return nil } visiting[node] = true for _, dep := range graph[node] { if err := visit(dep); err != nil { return err } } visiting[node] = false visited[node] = true return nil } for _, node := range nodes { if err := visit(node); err != nil { return err } } return nil }