384 lines
13 KiB
Go
384 lines
13 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
|
|
}
|
|
|
|
// 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))
|
|
}
|