439 lines
15 KiB
Go
439 lines
15 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"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 loader directives and family declarations.
|
|
func EffectivePipelineValues(cfg *PipelineConfig) ([]EffectivePipelineValueRecord, error) {
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("pipeline config is required")
|
|
}
|
|
return projectionValueRecords(projectEffectivePipeline(cfg, true))
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
values, err := EffectivePipelineValues(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
paths = append(paths, value.Path)
|
|
}
|
|
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. Players are derived from the
|
|
// canonical party source.
|
|
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})
|
|
}
|
|
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},
|
|
)
|
|
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")
|
|
}
|
|
|
|
value, err := projectionValueTree(projectEffectivePipeline(cfg, true))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
node, err := effectiveYAMLNode(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize effective pipeline: %w", err)
|
|
}
|
|
data, err := yaml.Marshal(node)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize effective pipeline: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func effectiveYAMLNode(value any) (*yaml.Node, error) {
|
|
switch value := value.(type) {
|
|
case nil:
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
|
case bool:
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(value)}, nil
|
|
case string:
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, nil
|
|
case json.Number:
|
|
tag := "!!int"
|
|
if strings.ContainsAny(value.String(), ".eE") {
|
|
tag = "!!float"
|
|
}
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value.String()}, nil
|
|
case []any:
|
|
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
if len(value) == 0 {
|
|
node.Style = yaml.FlowStyle
|
|
}
|
|
for _, item := range value {
|
|
child, err := effectiveYAMLNode(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
node.Content = append(node.Content, child)
|
|
}
|
|
return node, nil
|
|
case map[string]any:
|
|
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
if len(value) == 0 {
|
|
node.Style = yaml.FlowStyle
|
|
}
|
|
keys := make([]string, 0, len(value))
|
|
for key := range value {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
child, err := effectiveYAMLNode(value[key])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
node.Content = append(node.Content,
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, child)
|
|
}
|
|
return node, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported projected value type %T", value)
|
|
}
|
|
}
|
|
|
|
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...)
|
|
}
|
|
if cfg.Scriptorium != nil {
|
|
for key, artifact := range cfg.Scriptorium.Artifacts {
|
|
paths := artifactDependencyOwnershipPaths(key, artifact)
|
|
if sources := sourcesForPipelinePaths(owners, paths); len(sources) != 0 {
|
|
sort.Strings(sources)
|
|
owners["scriptorium.artifacts."+key+".depends_on"] = sources
|
|
}
|
|
}
|
|
}
|
|
return owners
|
|
}
|
|
|
|
func artifactDependencyOwnershipPaths(artifactKey string, artifact ScriptoriumArtifactConfig) []string {
|
|
var paths []string
|
|
for _, dependency := range artifact.DependsOn {
|
|
for _, origin := range artifact.dependencyOrigins[dependency] {
|
|
prefix := "scriptorium.artifacts." + artifactKey
|
|
if origin.family != "" {
|
|
prefix = "scriptorium.artifact_families." + origin.family
|
|
}
|
|
switch {
|
|
case origin.inputName != "":
|
|
paths = append(paths, prefix+".inputs."+origin.inputName+".source")
|
|
case origin.family != "":
|
|
paths = append(paths, prefix+".member_dependencies")
|
|
default:
|
|
paths = append(paths, prefix+".depends_on")
|
|
}
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
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{path, 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 source == "resolution" {
|
|
return "resolution"
|
|
}
|
|
if cfg == nil || cfg.resolution == nil {
|
|
return "pipeline"
|
|
}
|
|
if source == cfg.resolution.rootPath {
|
|
return "root"
|
|
}
|
|
if cfg.resolution.selectedOverlay != nil && source == cfg.resolution.selectedOverlay.overlayPath {
|
|
return "overlay"
|
|
}
|
|
for _, imported := range cfg.resolution.imports {
|
|
if source == imported {
|
|
return "import"
|
|
}
|
|
}
|
|
return "pipeline"
|
|
}
|
|
|
|
func normalizedProvenanceSource(source string) string {
|
|
if source == pipelineDefaultOwnershipSource || source == "resolution" {
|
|
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))
|
|
}
|