Files
narratio/internal/config/effective_pipeline.go

419 lines
14 KiB
Go

package config
import (
"fmt"
"path/filepath"
"sort"
"strings"
"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
}
// EffectivePipelineImports reports the ordered, absolute import paths that
// contributed to cfg.
func EffectivePipelineImports(cfg *PipelineConfig) []string {
if cfg == nil || cfg.resolution == nil {
return nil
}
return append([]string(nil), cfg.resolution.imports...)
}
// NormalizedConfigurationPath returns a clean absolute path when possible for
// display-only configuration provenance.
func NormalizedConfigurationPath(path string) string {
return normalizedProvenancePath(path)
}
// EffectivePipelineSourceRecord identifies one effective logical field and a
// safe source that contributed to it. Generated values intentionally have two
// records: their family declaration and the canonical party that supplied the
// member-specific value.
type EffectivePipelineSourceRecord struct {
Path string
Role string
Source string
}
// EffectivePipelineValueRecord identifies one normalized, secret-free
// effective configuration value. Values use deterministic compact JSON
// representations so command output can be compared without raw YAML layout.
type EffectivePipelineValueRecord struct {
Path string
Value string
}
// EffectivePipelineValues projects a fully resolved pipeline into sorted
// logical configuration values. Mappings are flattened, while sequences remain
// atomic values. The projection uses the same normalized effective mapping as
// config show and therefore excludes composition and family declarations.
func EffectivePipelineValues(cfg *PipelineConfig) ([]EffectivePipelineValueRecord, error) {
if cfg == nil {
return nil, fmt.Errorf("pipeline config is required")
}
data, err := MarshalEffectivePipeline(cfg)
if err != nil {
return nil, err
}
document, err := parseCompositionBytes("effective pipeline", data)
if err != nil {
return nil, err
}
records, err := document.compactSemanticRecords()
if err != nil {
return nil, err
}
values := make([]EffectivePipelineValueRecord, 0, len(records))
for _, record := range records {
values = append(values, EffectivePipelineValueRecord{Path: record.Path, Value: record.Value})
}
return values, nil
}
// EffectivePipelineSources projects pipeline ownership after defaults and
// optional family expansion. It never includes effective values or raw secret
// material, only logical paths and source identifiers.
func EffectivePipelineSources(cfg *PipelineConfig, party ResolvedParty) ([]EffectivePipelineSourceRecord, error) {
if cfg == nil {
return nil, fmt.Errorf("pipeline config is required")
}
data, err := MarshalEffectivePipeline(cfg)
if err != nil {
return nil, err
}
document, err := parseCompositionBytes("effective pipeline", data)
if err != nil {
return nil, err
}
paths := effectivePipelineFieldPaths(document.root)
owners := effectivePipelineOwners(cfg)
families := ArtifactFamilies(cfg)
publishCount := 0
if cfg.Publish != nil {
publishCount = len(cfg.Publish.Outputs)
}
records := make([]EffectivePipelineSourceRecord, 0, len(paths)+publishCount)
for _, path := range paths {
if path == "publish.outputs" {
continue
}
if familyKey, ok := generatedArtifactFamilyForPath(path, families); ok {
records = appendGeneratedArtifactSources(records, path, familyKey, families, owners, party)
continue
}
records = appendPipelineOwners(records, path, owners[path], cfg)
}
records = appendPublishOutputSources(records, cfg, families, party, owners)
sort.Slice(records, func(left, right int) bool {
if records[left].Path != records[right].Path {
return records[left].Path < records[right].Path
}
if records[left].Role != records[right].Role {
return records[left].Role < records[right].Role
}
return records[left].Source < records[right].Source
})
return records, nil
}
// EffectiveCampaignSources projects selected campaign and party ownership
// without retaining or rendering campaign values. Canonical players are a
// derived party value, while a legacy players file remains explicitly marked
// as legacy input provenance.
func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord {
if campaign == nil {
return nil
}
campaignSource := normalizedProvenancePath(campaignPath)
records := []EffectivePipelineSourceRecord{
{Path: "campaign.campaign_id", Role: "campaign", Source: campaignSource},
{Path: "campaign.inputs.autocorrect_file", Role: "campaign", Source: campaignSource},
{Path: "campaign.inputs.glossary_file", Role: "campaign", Source: campaignSource},
{Path: "campaign.inputs.speakers_file", Role: "campaign", Source: campaignSource},
}
if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" {
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource})
}
if party.Mode == PartyModeCanonical {
partySource := normalizedProvenancePath(party.Source.Path)
records = append(records,
EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource},
EffectivePipelineSourceRecord{Path: "derived.players", Role: "party", Source: partySource},
)
} else {
partySource := normalizedProvenancePath(party.Source.Path)
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource})
if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" {
records = append(records, EffectivePipelineSourceRecord{
Path: "campaign.inputs.players_file", Role: "legacy_player", Source: campaignInputSourcePath(campaignPath, campaign.Inputs.PlayersFile),
})
}
}
sort.Slice(records, func(left, right int) bool {
if records[left].Path != records[right].Path {
return records[left].Path < records[right].Path
}
if records[left].Role != records[right].Role {
return records[left].Role < records[right].Role
}
return records[left].Source < records[right].Source
})
return records
}
// 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
}
}
func effectivePipelineFieldPaths(node *compositionNode) []string {
var paths []string
var visit func(*compositionNode)
visit = func(current *compositionNode) {
if current == nil {
return
}
if current.kind == yaml.MappingNode && len(current.fields) > 0 {
for _, field := range current.fields {
visit(field.value)
}
return
}
paths = append(paths, current.path)
}
visit(node)
return paths
}
func effectivePipelineOwners(cfg *PipelineConfig) map[string][]string {
owners := make(map[string][]string)
if cfg == nil || cfg.resolution == nil {
return owners
}
for _, ownership := range cfg.resolution.ownership {
owners[ownership.path] = append([]string(nil), ownership.sources...)
}
return owners
}
func generatedArtifactFamilyForPath(path string, families ArtifactFamilyCatalog) (string, bool) {
const prefix = "scriptorium.artifacts."
if !strings.HasPrefix(path, prefix) {
return "", false
}
memberPath := strings.TrimPrefix(path, prefix)
memberKey, _, _ := strings.Cut(memberPath, ".")
member, exists := families.Members[memberKey]
if !exists {
return "", false
}
_, exists = families.Families[member.Family]
return member.Family, exists
}
func appendGeneratedArtifactSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord {
for _, source := range generatedArtifactSources(path, familyKey, families, owners) {
role := "family"
if source == pipelineDefaultOwnershipSource {
role = "default"
}
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)})
}
if source := normalizedProvenancePath(party.Source.Path); source != "" {
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source})
}
return records
}
func generatedArtifactSources(path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string) []string {
const artifactPrefix = "scriptorium.artifacts."
memberPath := strings.TrimPrefix(path, artifactPrefix)
_, suffix, _ := strings.Cut(memberPath, ".")
familyPrefix := "scriptorium.artifact_families." + familyKey + "."
candidates := []string{familyPrefix + suffix}
switch {
case suffix == "output_path":
candidates = []string{familyPrefix + "output_path_pattern"}
case suffix == "depends_on":
candidates = []string{familyPrefix + "depends_on", familyPrefix + "member_dependencies"}
case strings.HasPrefix(suffix, "vars."):
variable := strings.TrimPrefix(suffix, "vars.")
candidates = []string{familyPrefix + "vars." + variable, familyPrefix + "member_vars." + variable}
}
sources := sourcesForPipelinePaths(owners, candidates)
if len(sources) != 0 {
return sources
}
if family, ok := families.Families[familyKey]; ok && family.Source != "" {
return []string{family.Source}
}
return []string{pipelineDefaultOwnershipSource}
}
func sourcesForPipelinePaths(owners map[string][]string, paths []string) []string {
seen := make(map[string]struct{})
var sources []string
for _, path := range paths {
for _, source := range owners[path] {
if _, exists := seen[source]; exists {
continue
}
seen[source] = struct{}{}
sources = append(sources, source)
}
}
return sources
}
func appendPipelineOwners(records []EffectivePipelineSourceRecord, path string, sources []string, cfg *PipelineConfig) []EffectivePipelineSourceRecord {
if len(sources) == 0 {
sources = []string{pipelineDefaultOwnershipSource}
}
for _, source := range sources {
records = append(records, EffectivePipelineSourceRecord{
Path: path, Role: pipelineSourceRole(cfg, source), Source: normalizedProvenanceSource(source),
})
}
return records
}
func appendPublishOutputSources(records []EffectivePipelineSourceRecord, cfg *PipelineConfig, families ArtifactFamilyCatalog, party ResolvedParty, owners map[string][]string) []EffectivePipelineSourceRecord {
if cfg == nil || cfg.Publish == nil {
return records
}
generated := make(map[string]string)
for familyKey, family := range families.Families {
if family.Publish == nil || !family.Publish.Enabled {
continue
}
for _, key := range family.Members {
generated["narratio.artifact."+key] = familyKey
}
}
for index, output := range cfg.Publish.Outputs {
path := fmt.Sprintf("publish.outputs[%d]", index)
if familyKey, ok := generated[strings.TrimSpace(output.Source)]; ok {
records = appendGeneratedPublishSources(records, path, familyKey, families, owners, party)
continue
}
records = appendPipelineOwners(records, path, owners["publish.outputs"], cfg)
}
return records
}
func appendGeneratedPublishSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord {
familyPrefix := "scriptorium.artifact_families." + familyKey + ".publish."
sources := sourcesForPipelinePaths(owners, []string{familyPrefix + "enabled", familyPrefix + "required", familyPrefix + "dest_pattern"})
if len(sources) == 0 {
if family, ok := families.Families[familyKey]; ok && family.Source != "" {
sources = []string{family.Source}
} else {
sources = []string{pipelineDefaultOwnershipSource}
}
}
for _, source := range sources {
role := "family"
if source == pipelineDefaultOwnershipSource {
role = "default"
}
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)})
}
if source := normalizedProvenancePath(party.Source.Path); source != "" {
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source})
}
return records
}
func pipelineSourceRole(cfg *PipelineConfig, source string) string {
if source == pipelineDefaultOwnershipSource {
return "default"
}
if cfg == nil || cfg.resolution == nil {
return "pipeline"
}
if source == cfg.resolution.rootPath {
return "root"
}
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath {
return "profile"
}
for _, imported := range cfg.resolution.imports {
if source == imported {
return "import"
}
}
return "pipeline"
}
func normalizedProvenanceSource(source string) string {
if source == pipelineDefaultOwnershipSource {
return source
}
return normalizedProvenancePath(source)
}
func normalizedProvenancePath(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
abs, err := filepath.Abs(path)
if err != nil {
return filepath.Clean(path)
}
return filepath.Clean(abs)
}
func campaignInputSourcePath(campaignPath, configuredPath string) string {
if filepath.IsAbs(configuredPath) {
return normalizedProvenancePath(configuredPath)
}
return normalizedProvenancePath(filepath.Join(filepath.Dir(campaignPath), configuredPath))
}