Files
narratio/internal/config/validate_scriptorium.go

326 lines
11 KiB
Go

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]
for _, depName := range artifactCfg.DependsOn {
trimmedDep := strings.TrimSpace(depName)
field := dependencyOriginField(artifactName, trimmedDep, artifactCfg)
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("%s must not include itself", field)
}
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 != "" {
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 == "" {
field := dependencyOriginField(name, trimmedDep, cfg)
if field == fmt.Sprintf("pipeline.scriptorium.artifacts.%s.depends_on", name) {
return fmt.Errorf("%s entries must be non-empty", field)
}
return fmt.Errorf("%s must be non-empty", field)
}
if _, ok := artifacts[trimmedDep]; !ok {
return fmt.Errorf("%s dependency %q is not configured", dependencyOriginField(name, trimmedDep, cfg), dep)
}
if trimmedDep == name {
return fmt.Errorf("%s must not include itself", dependencyOriginField(name, trimmedDep, cfg))
}
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 visited[node] {
return nil
}
visiting[node] = true
for _, dep := range graph[node] {
if visiting[dep] {
return fmt.Errorf(
"%s creates a cycle; pipeline.scriptorium.artifacts dependencies must not contain cycles",
dependencyOriginField(node, dep, artifacts[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
}