464 lines
17 KiB
Go
464 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
|
)
|
|
|
|
const (
|
|
partyCharactersFamilySource = "party.characters"
|
|
characterIDToken = "{character_id}"
|
|
memberArtifactSourcePrefix = "narratio.member_artifact."
|
|
)
|
|
|
|
var memberVariableSelectors = map[string]func(PartyCharacter) string{
|
|
"character_id": func(character PartyCharacter) string { return character.ID },
|
|
"player.name": func(character PartyCharacter) string { return character.Player.Name },
|
|
"character.name": func(character PartyCharacter) string { return character.Character.Name },
|
|
"character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() },
|
|
"character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() },
|
|
}
|
|
|
|
func retainArtifactFamilyDeclarations(cfg *PipelineConfig) {
|
|
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil {
|
|
return
|
|
}
|
|
cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
|
|
}
|
|
|
|
func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error {
|
|
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded {
|
|
return nil
|
|
}
|
|
families := cfg.resolution.artifactFamilies
|
|
if len(families) == 0 {
|
|
families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
|
|
}
|
|
if len(families) == 0 {
|
|
return nil
|
|
}
|
|
if party.Mode != PartyModeCanonical || party.Canonical == nil {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party")
|
|
}
|
|
|
|
expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts)
|
|
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
|
|
return err
|
|
}
|
|
familyKeys := sortedFamilyKeys(families)
|
|
for _, familyKey := range familyKeys {
|
|
if !artifactpolicy.IsConfiguredKey(familyKey) {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$")
|
|
}
|
|
if _, exists := expanded[familyKey]; exists {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey)
|
|
}
|
|
}
|
|
if err := validateFamilyMemberDependencies(families, familyKeys); err != nil {
|
|
return err
|
|
}
|
|
|
|
characters := append([]PartyCharacter(nil), party.Canonical.Characters...)
|
|
sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID })
|
|
catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}}
|
|
outputOwners := make(map[string]string, len(expanded))
|
|
for key, artifact := range expanded {
|
|
if output := strings.TrimSpace(artifact.OutputPath); output != "" {
|
|
outputOwners[output] = key
|
|
}
|
|
}
|
|
for _, familyKey := range familyKeys {
|
|
family := families[familyKey]
|
|
prefix := "pipeline.scriptorium.artifact_families." + familyKey
|
|
if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource {
|
|
return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource)
|
|
}
|
|
if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil {
|
|
return err
|
|
}
|
|
familyOrigin := ArtifactFamilyOrigin{
|
|
Members: make([]string, 0, len(characters)),
|
|
MemberDependencies: append([]string(nil), family.MemberDependencies...),
|
|
Publish: cloneArtifactFamilyPublish(family.Publish),
|
|
Source: artifactFamilySource(cfg, familyKey),
|
|
}
|
|
for _, character := range characters {
|
|
key := familyKey + "_" + character.ID
|
|
if !artifactpolicy.IsConfiguredKey(key) {
|
|
return fmt.Errorf("%s generates invalid artifact key %q", prefix, key)
|
|
}
|
|
if _, exists := expanded[key]; exists {
|
|
return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key)
|
|
}
|
|
outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID)
|
|
if prior, exists := outputOwners[outputPath]; exists {
|
|
return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior)
|
|
}
|
|
vars, err := resolveFamilyVars(prefix, family, character)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dependencies, err := expandedFamilyDependencies(prefix, family, character.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inputs, err := expandFamilyInputs(prefix, family, character.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expanded[key] = ScriptoriumArtifactConfig{
|
|
Enabled: family.Enabled, DependsOn: dependencies, RenderDebug: family.RenderDebug,
|
|
PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout,
|
|
Inputs: inputs, Vars: vars,
|
|
}
|
|
outputOwners[outputPath] = key
|
|
familyOrigin.Members = append(familyOrigin.Members, key)
|
|
catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source, Dependencies: append([]string(nil), dependencies...), Inputs: inputSourceMap(inputs)}
|
|
}
|
|
catalog.Families[familyKey] = familyOrigin
|
|
}
|
|
cfg.Scriptorium.Artifacts = expanded
|
|
if err := expandFamilyPublishRules(cfg, catalog); err != nil {
|
|
return err
|
|
}
|
|
cfg.Scriptorium.ArtifactFamilies = nil
|
|
cfg.familyCatalog = catalog
|
|
cfg.resolution.artifactFamiliesExpanded = true
|
|
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
|
|
return err
|
|
}
|
|
if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil {
|
|
return err
|
|
}
|
|
if err := validatePublish(cfg.Publish, cfg.Scriptorium, cfg.Notarius); err != nil {
|
|
return err
|
|
}
|
|
if err := recomputePipelineEffectiveDigest(cfg); err != nil {
|
|
return fmt.Errorf("refresh expanded pipeline digest: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func expandFamilyPublishRules(cfg *PipelineConfig, catalog ArtifactFamilyCatalog) error {
|
|
if cfg == nil || cfg.Scriptorium == nil {
|
|
return nil
|
|
}
|
|
if cfg.Publish == nil {
|
|
for familyKey, family := range catalog.Families {
|
|
if family.Publish != nil && family.Publish.Enabled {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
generated := make([]PublishOutputRule, 0)
|
|
explicitSources := make(map[string]struct{}, len(cfg.Publish.Outputs))
|
|
for _, rule := range cfg.Publish.Outputs {
|
|
explicitSources[strings.TrimSpace(rule.Source)] = struct{}{}
|
|
}
|
|
for familyKey, family := range catalog.Families {
|
|
if family.Publish == nil || !family.Publish.Enabled {
|
|
continue
|
|
}
|
|
if cfg.resolution == nil || !cfg.resolution.publishDeclared {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey)
|
|
}
|
|
pattern := strings.TrimSpace(family.Publish.DestPattern)
|
|
if pattern != "" {
|
|
if err := validateFamilyOutputPattern("pipeline.scriptorium.artifact_families."+familyKey+".publish.dest_pattern", pattern); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, key := range family.Members {
|
|
if _, conflict := explicitSources[artifactpolicy.ConfiguredSourceID(key)]; conflict {
|
|
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish conflicts with explicit publish source %q", familyKey, artifactpolicy.ConfiguredSourceID(key))
|
|
}
|
|
origin := catalog.Members[key]
|
|
dest := cfg.Scriptorium.Artifacts[key].OutputPath
|
|
if pattern != "" {
|
|
dest = strings.ReplaceAll(pattern, characterIDToken, origin.CharacterID)
|
|
}
|
|
required := family.Publish.Required
|
|
generated = append(generated, PublishOutputRule{Source: artifactpolicy.ConfiguredSourceID(key), Dest: dest, Required: &required})
|
|
}
|
|
}
|
|
if len(generated) == 0 {
|
|
return nil
|
|
}
|
|
cfg.Publish.Outputs = append(cfg.Publish.Outputs, generated...)
|
|
sort.Slice(cfg.Publish.Outputs, func(i, j int) bool {
|
|
if cfg.Publish.Outputs[i].Dest == cfg.Publish.Outputs[j].Dest {
|
|
return cfg.Publish.Outputs[i].Source < cfg.Publish.Outputs[j].Source
|
|
}
|
|
return cfg.Publish.Outputs[i].Dest < cfg.Publish.Outputs[j].Dest
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func validateFamilyMemberDependencies(families map[string]ScriptoriumArtifactFamilyConfig, familyKeys []string) error {
|
|
for _, familyKey := range familyKeys {
|
|
family := families[familyKey]
|
|
seen := make(map[string]struct{}, len(family.MemberDependencies))
|
|
for index, raw := range family.MemberDependencies {
|
|
dependency := strings.TrimSpace(raw)
|
|
prefix := fmt.Sprintf("pipeline.scriptorium.artifact_families.%s.member_dependencies[%d]", familyKey, index)
|
|
if !artifactpolicy.IsConfiguredKey(dependency) {
|
|
return fmt.Errorf("%s must be a valid family key", prefix)
|
|
}
|
|
if dependency == familyKey {
|
|
return fmt.Errorf("%s must not reference its own family", prefix)
|
|
}
|
|
if _, duplicate := seen[dependency]; duplicate {
|
|
return fmt.Errorf("%s duplicates family %q", prefix, dependency)
|
|
}
|
|
seen[dependency] = struct{}{}
|
|
dependencyFamily, exists := families[dependency]
|
|
if !exists {
|
|
return fmt.Errorf("%s references unknown family %q", prefix, dependency)
|
|
}
|
|
if strings.TrimSpace(dependencyFamily.ForEach) != partyCharactersFamilySource {
|
|
return fmt.Errorf("%s family %q must use %q", prefix, dependency, partyCharactersFamilySource)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func expandedFamilyDependencies(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) ([]string, error) {
|
|
dependencies := append([]string(nil), family.DependsOn...)
|
|
memberDependencies := append([]string(nil), family.MemberDependencies...)
|
|
sort.Strings(memberDependencies)
|
|
for _, familyKey := range memberDependencies {
|
|
dependencies = append(dependencies, familyKey+"_"+characterID)
|
|
}
|
|
return normalizedFamilyDependencies(prefix, dependencies), nil
|
|
}
|
|
|
|
func normalizedFamilyDependencies(_ string, dependencies []string) []string {
|
|
seen := make(map[string]struct{}, len(dependencies))
|
|
normalized := make([]string, 0, len(dependencies))
|
|
for _, dependency := range dependencies {
|
|
dependency = strings.TrimSpace(dependency)
|
|
if _, exists := seen[dependency]; exists {
|
|
continue
|
|
}
|
|
seen[dependency] = struct{}{}
|
|
normalized = append(normalized, dependency)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func expandFamilyInputs(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) (map[string]ScriptoriumInputConfig, error) {
|
|
inputs := cloneArtifactInputs(family.Inputs)
|
|
for name, input := range inputs {
|
|
familyKey, matched, err := parseMemberArtifactSource(input.Source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s.inputs.%s.source: %w", prefix, name, err)
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
if !familyDependencyContains(family.MemberDependencies, familyKey) {
|
|
return nil, fmt.Errorf("%s.inputs.%s.source %q requires member_dependencies entry %q", prefix, name, input.Source, familyKey)
|
|
}
|
|
input.Source = artifactpolicy.ConfiguredSourceID(familyKey + "_" + characterID)
|
|
inputs[name] = input
|
|
}
|
|
return inputs, nil
|
|
}
|
|
|
|
func parseMemberArtifactSource(source string) (string, bool, error) {
|
|
trimmed := strings.TrimSpace(source)
|
|
if !strings.HasPrefix(trimmed, "narratio.member_artifact") {
|
|
return "", false, nil
|
|
}
|
|
if !strings.HasPrefix(trimmed, memberArtifactSourcePrefix) {
|
|
return "", true, fmt.Errorf("malformed member artifact source %q", source)
|
|
}
|
|
family := strings.TrimPrefix(trimmed, memberArtifactSourcePrefix)
|
|
if !artifactpolicy.IsConfiguredKey(family) {
|
|
return "", true, fmt.Errorf("malformed member artifact source %q", source)
|
|
}
|
|
return family, true, nil
|
|
}
|
|
|
|
func rejectConcreteMemberArtifactSources(artifacts map[string]ScriptoriumArtifactConfig) error {
|
|
for artifactKey, artifact := range artifacts {
|
|
for inputName, input := range artifact.Inputs {
|
|
if _, matched, err := parseMemberArtifactSource(input.Source); matched {
|
|
if err != nil {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source: %w", artifactKey, inputName, err)
|
|
}
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source uses member artifact syntax outside an artifact family", artifactKey, inputName)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func familyDependencyContains(values []string, want string) bool {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func inputSourceMap(inputs map[string]ScriptoriumInputConfig) map[string]string {
|
|
if len(inputs) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(inputs))
|
|
for name, input := range inputs {
|
|
out[name] = input.Source
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateFamilyOutputPattern(field, pattern string) error {
|
|
if strings.Count(pattern, characterIDToken) != 1 {
|
|
return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken)
|
|
}
|
|
remainder := strings.ReplaceAll(pattern, characterIDToken, "")
|
|
if strings.ContainsAny(remainder, "{}") {
|
|
return fmt.Errorf("%s contains unsupported brace syntax", field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) {
|
|
vars := make(map[string]any, len(family.Vars)+len(family.MemberVars))
|
|
for name, value := range family.Vars {
|
|
vars[name] = value
|
|
}
|
|
for name, selector := range family.MemberVars {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix)
|
|
}
|
|
if _, exists := vars[name]; exists {
|
|
return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name)
|
|
}
|
|
resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector)
|
|
}
|
|
vars[name] = resolve(character)
|
|
}
|
|
return vars, nil
|
|
}
|
|
|
|
func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string {
|
|
keys := make([]string, 0, len(families))
|
|
for key := range families {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func artifactFamilySource(cfg *PipelineConfig, family string) string {
|
|
if cfg == nil || cfg.resolution == nil {
|
|
return ""
|
|
}
|
|
prefix := "scriptorium.artifact_families." + family + "."
|
|
for _, ownership := range cfg.resolution.ownership {
|
|
if strings.HasPrefix(ownership.path, prefix) && len(ownership.sources) > 0 {
|
|
return ownership.sources[0]
|
|
}
|
|
}
|
|
return pipelineDefaultOwnershipSource
|
|
}
|
|
|
|
func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig {
|
|
if len(in) == 0 {
|
|
return map[string]ScriptoriumArtifactConfig{}
|
|
}
|
|
out := make(map[string]ScriptoriumArtifactConfig, len(in))
|
|
for key, artifact := range in {
|
|
artifact.DependsOn = append([]string(nil), artifact.DependsOn...)
|
|
artifact.Inputs = cloneArtifactInputs(artifact.Inputs)
|
|
artifact.Vars = cloneArtifactVars(artifact.Vars)
|
|
out[key] = artifact
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]ScriptoriumInputConfig, len(in))
|
|
for key, input := range in {
|
|
out[key] = input
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneArtifactVars(in map[string]any) map[string]any {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(in))
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in))
|
|
for key, family := range in {
|
|
family.DependsOn = append([]string(nil), family.DependsOn...)
|
|
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
|
|
family.Inputs = cloneArtifactInputs(family.Inputs)
|
|
family.Vars = cloneArtifactVars(family.Vars)
|
|
if len(family.MemberVars) > 0 {
|
|
memberVars := make(map[string]string, len(family.MemberVars))
|
|
for name, selector := range family.MemberVars {
|
|
memberVars[name] = selector
|
|
}
|
|
family.MemberVars = memberVars
|
|
}
|
|
family.Publish = cloneArtifactFamilyPublish(family.Publish)
|
|
out[key] = family
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
copy := *in
|
|
return ©
|
|
}
|
|
|
|
func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog {
|
|
out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))}
|
|
for key, family := range in.Families {
|
|
family.Members = append([]string(nil), family.Members...)
|
|
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
|
|
family.Publish = cloneArtifactFamilyPublish(family.Publish)
|
|
out.Families[key] = family
|
|
}
|
|
for key, member := range in.Members {
|
|
member.Dependencies = append([]string(nil), member.Dependencies...)
|
|
if len(member.Inputs) > 0 {
|
|
inputs := make(map[string]string, len(member.Inputs))
|
|
for name, source := range member.Inputs {
|
|
inputs[name] = source
|
|
}
|
|
member.Inputs = inputs
|
|
}
|
|
out.Members[key] = member
|
|
}
|
|
return out
|
|
}
|