Add read-only configuration inspection commands
This commit is contained in:
60
internal/config/effective_pipeline.go
Normal file
60
internal/config/effective_pipeline.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// EffectivePipelineRootPath reports the absolute root pipeline path retained
|
||||
// while resolving cfg. It is empty for a pipeline not loaded through the
|
||||
// production loader.
|
||||
func EffectivePipelineRootPath(cfg *PipelineConfig) string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.resolution.rootPath
|
||||
}
|
||||
|
||||
// MarshalEffectivePipeline renders the validated, normalized pipeline as one
|
||||
// deterministic YAML document. Composition declarations and runtime-only
|
||||
// resolution data are excluded. Artifact family declarations are also omitted
|
||||
// because a resolved pipeline exposes their concrete artifacts instead.
|
||||
func MarshalEffectivePipeline(cfg *PipelineConfig) ([]byte, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize effective pipeline: %w", err)
|
||||
}
|
||||
document, err := parseCompositionBytes("effective pipeline", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
removeResolutionOnlyPipelineFields(document)
|
||||
return document.canonicalYAML()
|
||||
}
|
||||
|
||||
func removeResolutionOnlyPipelineFields(document *compositionDocument) {
|
||||
if document == nil || document.root == nil {
|
||||
return
|
||||
}
|
||||
scriptoriumIndex := compositionFieldIndex(document.root.fields, "scriptorium")
|
||||
if scriptoriumIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium := document.root.fields[scriptoriumIndex].value
|
||||
if scriptorium == nil || scriptorium.kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
familyIndex := compositionFieldIndex(scriptorium.fields, "artifact_families")
|
||||
if familyIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium.fields = append(scriptorium.fields[:familyIndex], scriptorium.fields[familyIndex+1:]...)
|
||||
for index := range scriptorium.fields {
|
||||
scriptorium.fields[index].order = index
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,53 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte(`scriptorium:
|
||||
artifacts:
|
||||
zeta:
|
||||
enabled: false
|
||||
output_path: artifacts/zeta.md
|
||||
alpha:
|
||||
enabled: false
|
||||
output_path: artifacts/alpha.md
|
||||
workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstYAML, err := MarshalEffectivePipeline(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondYAML, err := MarshalEffectivePipeline(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") {
|
||||
t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML)
|
||||
}
|
||||
output := string(firstYAML)
|
||||
if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") {
|
||||
t.Fatalf("effective YAML leaked runtime fields: %q", output)
|
||||
}
|
||||
if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") {
|
||||
t.Fatalf("configured artifacts are not sorted: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingAudioSource(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Session.Inputs.AudioDir = ""
|
||||
|
||||
@@ -31,10 +31,10 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("session config is required")
|
||||
}
|
||||
|
||||
if err := validatePipeline(cfg.Pipeline); err != nil {
|
||||
if err := ValidatePipelineConfig(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 {
|
||||
if err := ValidateCampaignConfig(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 {
|
||||
@@ -50,6 +50,23 @@ func Validate(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePipelineConfig validates a loaded, defaulted pipeline without
|
||||
// requiring session configuration. Callers that require party-driven artifact
|
||||
// expansion must resolve a campaign first through LoadPipelineCampaign.
|
||||
func ValidatePipelineConfig(cfg *PipelineConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
return validatePipeline(cfg)
|
||||
}
|
||||
|
||||
// ValidateCampaignConfig validates a loaded campaign without requiring a
|
||||
// session. Party parsing and canonical-party checks remain owned by
|
||||
// LoadPipelineCampaign, which has the campaign source path available.
|
||||
func ValidateCampaignConfig(cfg *CampaignConfig) error {
|
||||
return validateCampaign(cfg)
|
||||
}
|
||||
|
||||
func validateCampaign(cfg *CampaignConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("campaign config is required")
|
||||
|
||||
Reference in New Issue
Block a user