package config import ( "errors" "fmt" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) type derivedPublishDestination struct { index int dest string } func deriveMissingPublishDestinations(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notarius *NotariusConfig) []derivedPublishDestination { if cfg == nil { return nil } configuredOutputs := scriptoriumOutputPathMap(scriptorium) extractionOutputs := notariusOutputKeySet(notarius) derived := make([]derivedPublishDestination, 0) for index, output := range cfg.Outputs { if strings.TrimSpace(output.Dest) != "" { continue } dest, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(strings.TrimSpace(output.Source), "", configuredOutputs, extractionOutputs) if err == nil { derived = append(derived, derivedPublishDestination{index: index, dest: dest}) } } return derived } func applyMissingPublishDestinations(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notarius *NotariusConfig) { for _, derived := range deriveMissingPublishDestinations(cfg, scriptorium, notarius) { cfg.Outputs[derived.index].Dest = derived.dest } } func validateStorage(cfg StorageConfig) error { backend := strings.ToLower(strings.TrimSpace(cfg.Backend)) switch backend { case "", StorageBackendLocal: if cfg.S3 != nil { return fmt.Errorf("pipeline.storage.s3 is only supported when pipeline.storage.backend is s3") } return nil case StorageBackendS3: if cfg.S3 == nil { return fmt.Errorf("pipeline.storage.s3 is required when pipeline.storage.backend is s3") } default: return fmt.Errorf("pipeline.storage.backend must be one of: local, s3") } 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 (cfg.S3.AccessKeyID == nil) != (cfg.S3.SecretAccessKey == nil) { return fmt.Errorf("pipeline.storage.s3.access_key_id and pipeline.storage.s3.secret_access_key must be configured together") } return nil } func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notarius *NotariusConfig) error { if cfg == nil { return nil } configuredOutputs := scriptoriumOutputPathMap(scriptorium) extractionOutputs := notariusOutputKeySet(notarius) 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.ValidatePublishSourceWithExtractions(source, configuredOutputs, extractionOutputs); err != nil { return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err) } dest := strings.TrimSpace(item.Dest) if dest == "" { return fmt.Errorf("%s.dest is required when destination cannot be derived from %q", prefix, source) } 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{}{} } return nil } func validateCleanupPublishPolicy(cfg *PipelineConfig) error { if cfg == nil || (!cfg.Spool.DeleteAudioAfterPublish && !cfg.Workspace.CleanupAfterPublish) { return nil } if cfg.Publish == nil || (cfg.Publish.Enabled != nil && !*cfg.Publish.Enabled) { return fmt.Errorf("cleanup stage policies require pipeline.publish.enabled=true") } if cfg.Publish.UploadRun != nil && !*cfg.Publish.UploadRun { return fmt.Errorf("cleanup stage policies require pipeline.publish.upload_run=true") } return nil } func scriptoriumOutputPathMap(scriptorium *ScriptoriumConfig) map[string]string { out := map[string]string{} if scriptorium == nil || len(scriptorium.Artifacts) == 0 { return out } for _, key := range sortedStringKeys(scriptorium.Artifacts) { artifactCfg := scriptorium.Artifacts[key] if !artifactpolicy.IsConfiguredKey(key) { continue } out[strings.TrimSpace(key)] = strings.TrimSpace(artifactCfg.OutputPath) } return out } func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { if pipeline == nil || pipeline.Publish == nil { return false } if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) { 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 }