255 lines
8.5 KiB
Go
255 lines
8.5 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
|
|
}
|
|
|
|
configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts))
|
|
referencedArtifacts := make(map[string]struct{})
|
|
outputOwners := make(map[string]string, len(cfg.Artifacts))
|
|
for artifactName := range cfg.Artifacts {
|
|
if !artifactpolicy.IsConfiguredKey(artifactName) {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
|
}
|
|
configuredArtifacts[artifactName] = struct{}{}
|
|
}
|
|
|
|
for artifactName, artifactCfg := range cfg.Artifacts {
|
|
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.output_path %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
|
|
}
|
|
|
|
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 fmt.Errorf("%s must be non-empty", field)
|
|
}
|
|
if _, ok := configuredArtifacts[trimmedDep]; !ok {
|
|
return fmt.Errorf("%s %q is not a configured artifact key", field, depName)
|
|
}
|
|
if trimmedDep == artifactName {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", artifactName)
|
|
}
|
|
depSet[trimmedDep] = struct{}{}
|
|
referencedArtifacts[trimmedDep] = struct{}{}
|
|
}
|
|
|
|
for inputName, inputCfg := range artifactCfg.Inputs {
|
|
trimmedInputName := strings.TrimSpace(inputName)
|
|
if trimmedInputName == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName)
|
|
}
|
|
source := strings.TrimSpace(inputCfg.Source)
|
|
if source == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName)
|
|
}
|
|
|
|
referencedArtifact, err := validateScriptoriumInputSource(artifactName, inputName, source, configuredArtifacts, notariusOutputKeySet(notarius))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if referencedArtifact != "" {
|
|
if _, ok := depSet[referencedArtifact]; !ok {
|
|
return fmt.Errorf(
|
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q requires depends_on entry %q",
|
|
artifactName,
|
|
inputName,
|
|
source,
|
|
referencedArtifact,
|
|
)
|
|
}
|
|
referencedArtifacts[referencedArtifact] = struct{}{}
|
|
}
|
|
}
|
|
for varName, varValue := range artifactCfg.Vars {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
for artifactName := range referencedArtifacts {
|
|
artifactCfg := cfg.Artifacts[artifactName]
|
|
if strings.TrimSpace(artifactCfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when artifact is referenced", artifactName)
|
|
}
|
|
}
|
|
|
|
if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
graph := make(map[string][]string, len(artifacts))
|
|
for name, cfg := range artifacts {
|
|
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
|
|
}
|
|
|
|
nodes := make([]string, 0, len(artifacts))
|
|
for node := range artifacts {
|
|
nodes = append(nodes, node)
|
|
}
|
|
sort.Strings(nodes)
|
|
for _, node := range nodes {
|
|
if err := visit(node); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|