813 lines
26 KiB
Go
813 lines
26 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
)
|
|
|
|
// Validate checks resolved configuration for required fields and parseable durations.
|
|
func Validate(cfg *Config) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config is nil")
|
|
}
|
|
if cfg.Pipeline == nil {
|
|
return fmt.Errorf("pipeline config is required")
|
|
}
|
|
if cfg.Campaign == nil {
|
|
return fmt.Errorf("campaign config is required")
|
|
}
|
|
if cfg.Session == nil {
|
|
return fmt.Errorf("session config is required")
|
|
}
|
|
|
|
if err := validatePipeline(cfg.Pipeline); err != nil {
|
|
return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err)
|
|
}
|
|
if err := validateCampaign(cfg.Campaign); err != nil {
|
|
return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err)
|
|
}
|
|
if err := validateSession(cfg.Session); err != nil {
|
|
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
|
}
|
|
if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil {
|
|
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateCampaign(cfg *CampaignConfig) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("campaign config is required")
|
|
}
|
|
if CampaignID(cfg) == "" {
|
|
return fmt.Errorf("campaign.campaign_id is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePipeline(cfg *PipelineConfig) error {
|
|
if strings.TrimSpace(cfg.Workspace.Root) == "" {
|
|
return fmt.Errorf("pipeline.workspace.root is required")
|
|
}
|
|
if err := validateSecrets(cfg.Secrets); err != nil {
|
|
return err
|
|
}
|
|
if err := validateStorage(cfg.Storage); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSpool(cfg.Spool); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCache(cfg.Cache); err != nil {
|
|
return err
|
|
}
|
|
if err := validatePublish(cfg.Publish, cfg.Scriptorium); err != nil {
|
|
return err
|
|
}
|
|
if err := validateWhisperX(cfg.WhisperX); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSeriatim(cfg.Seriatim); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAudita(cfg.Audita); err != nil {
|
|
return err
|
|
}
|
|
if err := validateNormalize(cfg.Normalize); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTrim(cfg.Trim); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRender(cfg.Render); err != nil {
|
|
return err
|
|
}
|
|
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
|
return err
|
|
}
|
|
if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateStorage(cfg StorageConfig) error {
|
|
if cfg.S3 == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.S3.RootPrefix) == "" {
|
|
return fmt.Errorf("pipeline.storage.s3.root_prefix must be non-empty")
|
|
}
|
|
if err := validateRelativeSafePath("pipeline.storage.s3.root_prefix", cfg.S3.RootPrefix); err != nil {
|
|
return err
|
|
}
|
|
if cfg.S3.Endpoint != "" && strings.TrimSpace(cfg.S3.Endpoint) == "" {
|
|
return fmt.Errorf("pipeline.storage.s3.endpoint must be non-empty when provided")
|
|
}
|
|
if err := validateEnvVarNameField("pipeline.storage.s3.access_key_id_env", cfg.S3.AccessKeyIDEnv); err != nil {
|
|
return err
|
|
}
|
|
if err := validateEnvVarNameField("pipeline.storage.s3.secret_access_key_env", cfg.S3.SecretKeyEnv); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSpool(cfg SpoolConfig) error {
|
|
return nil
|
|
}
|
|
|
|
func validateCache(cfg CacheConfig) error {
|
|
if cfg.S3Audio != nil && *cfg.S3Audio && strings.TrimSpace(cfg.Root) == "" {
|
|
return fmt.Errorf("pipeline.cache.root is required when pipeline.cache.s3_audio is true")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
source := strings.TrimSpace(item.Source)
|
|
if source == "" {
|
|
return fmt.Errorf("%s.source is required", prefix)
|
|
}
|
|
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 := 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)
|
|
}
|
|
dest = derivedDest
|
|
cfg.Outputs[i].Dest = derivedDest
|
|
}
|
|
normalizedDest, err := pathsafe.NormalizeRelativeDestination(dest)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, pathsafe.ErrRelativePathAbsolute):
|
|
return fmt.Errorf("%s.dest must be a relative path", prefix)
|
|
case errors.Is(err, pathsafe.ErrRelativePathEscape):
|
|
return fmt.Errorf("%s.dest must not contain path traversal", prefix)
|
|
default:
|
|
return fmt.Errorf("%s.dest must be non-empty", prefix)
|
|
}
|
|
}
|
|
if _, ok := seenDest[normalizedDest]; ok {
|
|
return fmt.Errorf("%s.dest %q duplicates another publish output destination", prefix, dest)
|
|
}
|
|
seenDest[normalizedDest] = struct{}{}
|
|
}
|
|
locks, err := ValidatePublishLockRules(cfg.Locks, scriptorium, "pipeline.publish.locks")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Locks = locks
|
|
return nil
|
|
}
|
|
|
|
// ValidatePublishLockRules validates and normalizes source-based publish locks.
|
|
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"
|
|
}
|
|
for i, item := range locks {
|
|
prefix := fmt.Sprintf("%s[%d]", label, i)
|
|
source := strings.TrimSpace(item.Source)
|
|
if source == "" {
|
|
return nil, fmt.Errorf("%s.source is required", prefix)
|
|
}
|
|
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 {
|
|
return nil, fmt.Errorf("%s.source %q duplicates another publish lock source", prefix, source)
|
|
}
|
|
seenLocks[source] = struct{}{}
|
|
out = append(out, PublishLockRule{
|
|
Source: source,
|
|
Reason: strings.TrimSpace(item.Reason),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// MergePublishLockRules returns the union of static and remote locks. Static
|
|
// locks win when both sources contain the same lock.
|
|
func MergePublishLockRules(staticLocks, remoteLocks []PublishLockRule) []PublishLockRule {
|
|
out := make([]PublishLockRule, 0, len(staticLocks)+len(remoteLocks))
|
|
seen := map[string]struct{}{}
|
|
for _, item := range staticLocks {
|
|
source := strings.TrimSpace(item.Source)
|
|
if source == "" {
|
|
continue
|
|
}
|
|
out = append(out, PublishLockRule{Source: source, Reason: strings.TrimSpace(item.Reason)})
|
|
seen[source] = struct{}{}
|
|
}
|
|
for _, item := range remoteLocks {
|
|
source := strings.TrimSpace(item.Source)
|
|
if source == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[source]; ok {
|
|
continue
|
|
}
|
|
out = append(out, PublishLockRule{Source: source, Reason: strings.TrimSpace(item.Reason)})
|
|
seen[source] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scriptoriumOutputPathMap(scriptorium *ScriptoriumConfig) map[string]string {
|
|
out := map[string]string{}
|
|
if scriptorium == nil || len(scriptorium.Artifacts) == 0 {
|
|
return out
|
|
}
|
|
for key, artifactCfg := range scriptorium.Artifacts {
|
|
out[strings.TrimSpace(key)] = strings.TrimSpace(artifactCfg.OutputPath)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateSecrets(cfg *SecretsConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.EnvDir) == "" {
|
|
return fmt.Errorf("pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateNormalize(cfg *NormalizeConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.normalize.output_path must be non-empty")
|
|
}
|
|
if err := validateSeriatimOutputSchema("pipeline.normalize.output_schema", cfg.OutputSchema); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTrim(cfg *TrimConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.output_path is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.PromptID) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true")
|
|
}
|
|
if err := validateDuration("pipeline.trim.bounds.timeout", cfg.Bounds.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if cfg.Bounds.RenderDebug && strings.TrimSpace(cfg.Bounds.RenderOutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateRender(cfg *RenderConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if cfg.Enabled == nil {
|
|
return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)")
|
|
}
|
|
if cfg.IncludeTimestamps == nil {
|
|
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)")
|
|
}
|
|
format := strings.TrimSpace(cfg.Format)
|
|
if format != "markdown" {
|
|
return fmt.Errorf("pipeline.render.format must be markdown")
|
|
}
|
|
if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" {
|
|
return fmt.Errorf("pipeline.render.title must be non-empty when provided")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateWhisperX(cfg WhisperXConfig) error {
|
|
if strings.TrimSpace(cfg.TranscribeURL) == "" {
|
|
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
|
|
}
|
|
u, err := url.Parse(cfg.TranscribeURL)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
if err != nil {
|
|
return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid URL: %w", err)
|
|
}
|
|
return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid URL")
|
|
}
|
|
if err := validateDuration("pipeline.whisperx.timeout", cfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if err := validateDuration("pipeline.whisperx.retry_delay", cfg.RetryDelay); err != nil {
|
|
return err
|
|
}
|
|
if cfg.Retries == nil {
|
|
return fmt.Errorf("pipeline.whisperx.retries must be set (defaults should populate this)")
|
|
}
|
|
if *cfg.Retries < 0 {
|
|
return fmt.Errorf("pipeline.whisperx.retries must be >= 0")
|
|
}
|
|
if cfg.Concurrency == nil {
|
|
return fmt.Errorf("pipeline.whisperx.concurrency must be set (defaults should populate this)")
|
|
}
|
|
if *cfg.Concurrency <= 0 {
|
|
return fmt.Errorf("pipeline.whisperx.concurrency must be > 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSeriatim(cfg SeriatimConfig) error {
|
|
if strings.TrimSpace(cfg.Binary) == "" {
|
|
return fmt.Errorf("pipeline.seriatim.binary is required")
|
|
}
|
|
if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil {
|
|
return err
|
|
}
|
|
|
|
if cfg.CoalesceGap == nil {
|
|
return fmt.Errorf("pipeline.seriatim.coalesce_gap must be set (defaults should populate this)")
|
|
}
|
|
if *cfg.CoalesceGap < 0 {
|
|
return fmt.Errorf("pipeline.seriatim.coalesce_gap must be >= 0")
|
|
}
|
|
|
|
for _, item := range []struct {
|
|
name string
|
|
value *float64
|
|
}{
|
|
{name: "pipeline.seriatim.env.overlap_word_run_gap", value: cfg.Env.OverlapWordRunGap},
|
|
{name: "pipeline.seriatim.env.overlap_word_run_reorder_window", value: cfg.Env.OverlapWordRunReorderWindow},
|
|
{name: "pipeline.seriatim.env.backchannel_max_duration", value: cfg.Env.BackchannelMaxDuration},
|
|
{name: "pipeline.seriatim.env.filler_max_duration", value: cfg.Env.FillerMaxDuration},
|
|
} {
|
|
if item.value != nil && *item.value <= 0 {
|
|
return fmt.Errorf("%s must be > 0 when provided", item.name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSeriatimOutputSchema(field, value string) error {
|
|
switch strings.TrimSpace(value) {
|
|
case "seriatim-minimal", "seriatim-intermediate", "seriatim-full":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%s must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", field)
|
|
}
|
|
}
|
|
|
|
func validateAudita(cfg AuditaConfig) error {
|
|
if strings.TrimSpace(cfg.Binary) == "" {
|
|
return fmt.Errorf("pipeline.audita.binary is required")
|
|
}
|
|
if err := validateDuration("pipeline.audita.timeout", cfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
for i, m := range cfg.Modules {
|
|
module := strings.TrimSpace(m)
|
|
if module == "" {
|
|
return fmt.Errorf("pipeline.audita.modules[%d] must be non-empty", i)
|
|
}
|
|
switch module {
|
|
case "glossary", "homophones", "spoken_word", "grammar":
|
|
default:
|
|
return fmt.Errorf("pipeline.audita.modules[%d] must be one of: glossary, homophones, spoken_word, grammar", i)
|
|
}
|
|
}
|
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
|
u, err := url.Parse(cfg.BaseURL)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
if err != nil {
|
|
return fmt.Errorf("pipeline.audita.base_url must be a valid URL: %w", err)
|
|
}
|
|
return fmt.Errorf("pipeline.audita.base_url must be a valid URL")
|
|
}
|
|
}
|
|
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
|
|
return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0")
|
|
}
|
|
if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 {
|
|
return fmt.Errorf("pipeline.audita.proposal_llm_concurrency must be > 0")
|
|
}
|
|
if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 {
|
|
return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be > 0")
|
|
}
|
|
if strings.TrimSpace(cfg.TranscriptDescription) == "" && cfg.TranscriptDescription != "" {
|
|
return fmt.Errorf("pipeline.audita.transcript_description must be non-empty when provided")
|
|
}
|
|
if strings.TrimSpace(cfg.ConfigPath) == "" && cfg.ConfigPath != "" {
|
|
return fmt.Errorf("pipeline.audita.config_path must be non-empty when provided")
|
|
}
|
|
switch strings.TrimSpace(cfg.OutputSchema) {
|
|
case "", "bare-segments", "audita-v1":
|
|
default:
|
|
return fmt.Errorf("pipeline.audita.output_schema must be one of: bare-segments, audita-v1")
|
|
}
|
|
switch strings.TrimSpace(cfg.WorkDirRetention) {
|
|
case "", "always", "auto", "never":
|
|
default:
|
|
return fmt.Errorf("pipeline.audita.work_dir_retention must be one of: always, auto, never")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScriptorium(cfg *ScriptoriumConfig) 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{})
|
|
for artifactName := range cfg.Artifacts {
|
|
if !scriptoriumArtifactKeyRE.MatchString(strings.TrimSpace(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 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)
|
|
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 := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSession(cfg *SessionConfig) error {
|
|
if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) {
|
|
return fmt.Errorf("session.previous_session_id must not equal session.session_id")
|
|
}
|
|
if strings.TrimSpace(cfg.Campaign) == "" {
|
|
return fmt.Errorf("session.campaign is required")
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
|
|
return fmt.Errorf("session.inputs.speakers_file is required")
|
|
}
|
|
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
|
|
return fmt.Errorf("session.inputs.autocorrect_file is required")
|
|
}
|
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
|
return fmt.Errorf("session.inputs.glossary_file is required")
|
|
}
|
|
|
|
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
|
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
|
hasAudioS3 := cfg.Inputs.AudioS3 != nil
|
|
if hasAudioS3 {
|
|
if strings.TrimSpace(cfg.Inputs.AudioS3.Prefix) == "" {
|
|
return fmt.Errorf("session.inputs.audio_s3.prefix is required when session.inputs.audio_s3 is configured")
|
|
}
|
|
if err := validateRelativeSafePath("session.inputs.audio_s3.prefix", cfg.Inputs.AudioS3.Prefix); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hasAudioS3 && (hasAudioDir || hasAudioFiles) {
|
|
return fmt.Errorf("session.inputs.audio_dir/audio_files and session.inputs.audio_s3 are mutually exclusive")
|
|
}
|
|
if !hasAudioDir && !hasAudioFiles && !hasAudioS3 {
|
|
return fmt.Errorf("session.inputs requires audio_dir, at least one audio_files entry, or audio_s3")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
|
if strings.TrimSpace(value) == "" {
|
|
if required {
|
|
return fmt.Errorf("%s is required", fieldName)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
|
|
if pipeline == nil || session == nil {
|
|
return nil
|
|
}
|
|
if pipeline.Storage.S3 == nil {
|
|
return nil
|
|
}
|
|
|
|
audioS3Enabled := session.Inputs.AudioS3 != nil
|
|
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
|
|
if (audioS3Enabled || publishUploadEnabled) && strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" {
|
|
return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool {
|
|
if pipeline == nil || pipeline.Publish == nil {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), "s3") {
|
|
return false
|
|
}
|
|
enabled := true
|
|
if pipeline.Publish.Enabled != nil {
|
|
enabled = *pipeline.Publish.Enabled
|
|
}
|
|
upload := true
|
|
if pipeline.Publish.UploadRun != nil {
|
|
upload = *pipeline.Publish.UploadRun
|
|
}
|
|
return enabled && upload
|
|
}
|
|
|
|
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_]*$`)
|
|
|
|
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts 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.ValidateInputConfiguredReference(descriptor, configuredArtifacts); 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,
|
|
)
|
|
}
|
|
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
|
|
}
|
|
|
|
func validateEnvVarNameField(fieldName, value string) error {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("%s must be non-empty", fieldName)
|
|
}
|
|
if !envVarNameRE.MatchString(trimmed) {
|
|
return fmt.Errorf("%s must be a valid environment variable name", fieldName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRelativeSafePath(fieldName, value string) error {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("%s must be non-empty", fieldName)
|
|
}
|
|
if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || windowsAbsPathRE.MatchString(trimmed) {
|
|
return fmt.Errorf("%s must be a relative path", fieldName)
|
|
}
|
|
|
|
normalized := strings.ReplaceAll(trimmed, "\\", "/")
|
|
for _, segment := range strings.Split(normalized, "/") {
|
|
if segment == ".." {
|
|
return fmt.Errorf("%s must not contain path traversal", fieldName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePathWithinRoot(fieldName, value, root string) error {
|
|
normalizedValue := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value)))
|
|
normalizedRoot := filepath.ToSlash(filepath.Clean(strings.TrimSpace(root)))
|
|
if normalizedValue == normalizedRoot {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(normalizedValue, normalizedRoot+"/") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
|
|
}
|
|
|
|
func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error {
|
|
if len(artifacts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
enabled := make(map[string]struct{}, len(artifacts))
|
|
graph := make(map[string][]string, len(artifacts))
|
|
for name, cfg := range artifacts {
|
|
if !cfg.Enabled {
|
|
continue
|
|
}
|
|
enabled[name] = struct{}{}
|
|
}
|
|
for name, cfg := range artifacts {
|
|
if !cfg.Enabled {
|
|
continue
|
|
}
|
|
for _, dep := range cfg.DependsOn {
|
|
trimmedDep := strings.TrimSpace(dep)
|
|
if _, ok := enabled[trimmedDep]; ok {
|
|
graph[name] = append(graph[name], trimmedDep)
|
|
}
|
|
}
|
|
}
|
|
|
|
visiting := make(map[string]bool, len(enabled))
|
|
visited := make(map[string]bool, len(enabled))
|
|
|
|
var visit func(node string) error
|
|
visit = func(node string) error {
|
|
if visiting[node] {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts enabled 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 enabled {
|
|
if err := visit(node); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDuration(fieldName, value string) error {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
|
|
if _, err := time.ParseDuration(trimmed); err != nil {
|
|
return fmt.Errorf("%s must be a valid duration: %w", fieldName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|