478 lines
18 KiB
Go
478 lines
18 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadPipelineProfilesSelectDefaultAndExplicitOverlay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, `composition:
|
|
imports: [conf.d/base.yml]
|
|
default_profile: production
|
|
profiles:
|
|
production:
|
|
overlay: profiles/production.yml
|
|
testing:
|
|
overlay: profiles/testing.yml
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`)
|
|
importPath := writePipelineSource(t, dir, "conf.d/base.yml", `workspace:
|
|
root: /srv/base
|
|
audita:
|
|
modules: [base, shared]
|
|
`)
|
|
productionPath := writePipelineSource(t, dir, "profiles/production.yml", `workspace:
|
|
root: /srv/production
|
|
whisperx:
|
|
language: en
|
|
`)
|
|
testingPath := writePipelineSource(t, dir, "profiles/testing.yml", `workspace:
|
|
root: /srv/testing
|
|
whisperx:
|
|
language: fr
|
|
`)
|
|
|
|
production, err := LoadPipeline(rootPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if production.Workspace.Root != "/srv/production" || production.WhisperX.Language != "en" {
|
|
t.Fatalf("default profile result = workspace=%q language=%q", production.Workspace.Root, production.WhisperX.Language)
|
|
}
|
|
assertSelectedPipelineProfile(t, production, "production", "default", productionPath)
|
|
if want := []string{absolutePath(t, rootPath), absolutePath(t, importPath), absolutePath(t, productionPath)}; !reflect.DeepEqual(production.resolution.sources, want) {
|
|
t.Fatalf("default sources = %#v, want %#v", production.resolution.sources, want)
|
|
}
|
|
assertPipelineFieldOwner(t, production, "workspace.root", absolutePath(t, productionPath))
|
|
assertPipelineFieldOwner(t, production, "audita.modules", absolutePath(t, importPath))
|
|
assertPipelineFieldOwner(t, production, "storage.backend", pipelineDefaultOwnershipSource)
|
|
|
|
profile := "testing"
|
|
testingCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &profile})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if testingCfg.Workspace.Root != "/srv/testing" || testingCfg.WhisperX.Language != "fr" {
|
|
t.Fatalf("explicit profile result = workspace=%q language=%q", testingCfg.Workspace.Root, testingCfg.WhisperX.Language)
|
|
}
|
|
assertSelectedPipelineProfile(t, testingCfg, "testing", "cli", testingPath)
|
|
}
|
|
|
|
func TestLoadPipelineProfileSelectionErrors(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
root string
|
|
profile *string
|
|
want string
|
|
}{
|
|
{name: "profiles require selection", root: profileComposition("", "production"), want: "default_profile is omitted"},
|
|
{name: "unknown explicit", root: profileComposition("production", "production"), profile: profilePointer("unknown"), want: "not declared"},
|
|
{name: "stacked explicit", root: profileComposition("production", "production", "testing"), profile: profilePointer("production,testing"), want: "not declared"},
|
|
{name: "explicit empty", root: profileComposition("production", "production"), profile: profilePointer(""), want: "must be non-empty"},
|
|
{name: "explicit whitespace", root: profileComposition("production", "production"), profile: profilePointer(" production "), want: "surrounding whitespace"},
|
|
{name: "invalid default", root: profileComposition("unknown", "production"), want: "does not name a declared profile"},
|
|
{name: "empty default", root: profileComposition("EMPTY", "production"), want: "must be non-empty"},
|
|
{name: "empty profile name", root: `composition:
|
|
default_profile: production
|
|
profiles:
|
|
"":
|
|
overlay: production.yml
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`, want: "profile name must be non-empty"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
root := test.root
|
|
if test.name == "empty default" {
|
|
root = strings.ReplaceAll(root, "default_profile: EMPTY", `default_profile: ""`)
|
|
}
|
|
rootPath := writeProfilePipeline(t, dir, root)
|
|
writePipelineSource(t, dir, "production.yml", "whisperx:\n language: en\n")
|
|
_, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: test.profile})
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
|
t.Fatalf("LoadPipelineWithOptions() error = %v, want containing %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("explicit against profile-free pipeline", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, testPipelineBaseYAML)
|
|
selected := "testing"
|
|
_, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &selected})
|
|
if err == nil || !strings.Contains(err.Error(), "declares no profiles") {
|
|
t.Fatalf("error = %v, want profile-free rejection", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPipelineProfileNamesRejectControlCharacters(t *testing.T) {
|
|
if _, err := normalizePipelineProfileName("production\x00testing", "profile"); err == nil || !strings.Contains(err.Error(), "control characters") {
|
|
t.Fatalf("normalizePipelineProfileName() error = %v, want control-character rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadPipelineProfilesValidateEveryDeclaredOverlay(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
path string
|
|
content string
|
|
setup func(*testing.T, string)
|
|
want string
|
|
}{
|
|
{name: "missing", path: "profiles/missing.yml", want: "open composition.profiles.testing.overlay"},
|
|
{name: "malformed", path: "profiles/testing.yml", content: "workspace: [\n", want: "decode yaml"},
|
|
{name: "duplicate keys", path: "profiles/testing.yml", content: "workspace:\n root: /one\n root: /two\n", want: "duplicate yaml key"},
|
|
{name: "trailing document", path: "profiles/testing.yml", content: "workspace:\n root: /one\n---\nworkspace:\n root: /two\n", want: "exactly one yaml document"},
|
|
{name: "nested composition", path: "profiles/testing.yml", content: "composition:\n imports: [nested.yml]\n", want: "only the root pipeline"},
|
|
{name: "inheritance", path: "profiles/testing.yml", content: "workspace:\n root: /testing\n", setup: func(t *testing.T, dir string) {
|
|
rootPath := filepath.Join(dir, "pipeline.yml")
|
|
data, err := os.ReadFile(rootPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated := strings.Replace(string(data), "testing:\n overlay:", "testing:\n extends: production\n overlay:", 1)
|
|
if err := os.WriteFile(rootPath, []byte(updated), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}, want: "only overlay is supported"},
|
|
{name: "traversal", path: "../testing.yml", want: "invalid"},
|
|
{name: "extension", path: "profiles/testing.json", content: "{}\n", want: ".yml or .yaml"},
|
|
{name: "directory", path: "profiles/testing.yml", setup: func(t *testing.T, dir string) {
|
|
if err := os.MkdirAll(filepath.Join(dir, "profiles/testing.yml"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}, want: "regular file"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, `composition:
|
|
default_profile: production
|
|
profiles:
|
|
production:
|
|
overlay: profiles/production.yml
|
|
testing:
|
|
overlay: `+test.path+`
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`)
|
|
writePipelineSource(t, dir, "profiles/production.yml", "whisperx:\n language: en\n")
|
|
if test.content != "" {
|
|
writePipelineSource(t, dir, test.path, test.content)
|
|
}
|
|
if test.setup != nil {
|
|
test.setup(t, dir)
|
|
}
|
|
_, err := LoadPipeline(rootPath)
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
|
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPipelineProfileOverlaySemantics(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, `composition:
|
|
default_profile: testing
|
|
profiles:
|
|
testing:
|
|
overlay: testing.yml
|
|
workspace:
|
|
root: /base
|
|
cleanup_after_publish: true
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
retries: 3
|
|
audita:
|
|
modules: [base, shared]
|
|
scriptorium:
|
|
artifacts:
|
|
session_recap:
|
|
enabled: true
|
|
prompt_id: dnd.session_recap
|
|
output_path: artifacts/session_recap.md
|
|
`)
|
|
writePipelineSource(t, dir, "testing.yml", `workspace:
|
|
cleanup_after_publish: false
|
|
whisperx:
|
|
retries: 0
|
|
audita:
|
|
modules: []
|
|
scriptorium:
|
|
artifacts:
|
|
session_recap:
|
|
enabled: false
|
|
experimental:
|
|
enabled: false
|
|
`)
|
|
|
|
cfg, err := LoadPipeline(rootPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Workspace.Root != "/base" || cfg.Workspace.CleanupAfterPublish || cfg.WhisperX.Retries == nil || *cfg.WhisperX.Retries != 0 {
|
|
t.Fatalf("recursive/zero overlay = workspace=%#v retries=%#v", cfg.Workspace, cfg.WhisperX.Retries)
|
|
}
|
|
if cfg.Audita.Modules == nil || len(cfg.Audita.Modules) != 0 {
|
|
t.Fatalf("list replacement = %#v, want explicit empty list", cfg.Audita.Modules)
|
|
}
|
|
if cfg.Scriptorium == nil || cfg.Scriptorium.Artifacts["session_recap"].Enabled || len(cfg.Scriptorium.Artifacts) != 2 {
|
|
t.Fatalf("keyed artifact overlay = %#v", cfg.Scriptorium)
|
|
}
|
|
}
|
|
|
|
func TestLoadPipelineProfileRejectsNullAndKindChanges(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
overlay string
|
|
want string
|
|
}{
|
|
{name: "null deletion", overlay: "workspace:\n root: null\n", want: "null cannot delete"},
|
|
{name: "mapping scalar", overlay: "workspace: /other\n", want: "kind change"},
|
|
{name: "list mapping", overlay: "audita:\n modules:\n testing: true\n", want: "kind change"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, `composition:
|
|
default_profile: testing
|
|
profiles:
|
|
testing:
|
|
overlay: testing.yml
|
|
workspace:
|
|
root: /base
|
|
audita:
|
|
modules: [base]
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`)
|
|
writePipelineSource(t, dir, "testing.yml", test.overlay)
|
|
_, err := LoadPipeline(rootPath)
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
|
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPipelineEffectiveDigestTracksNormalizedMeaning(t *testing.T) {
|
|
dir := t.TempDir()
|
|
monolithicPath := writePipelineSource(t, dir, "monolithic.yml", `workspace:
|
|
root: /srv/narratio
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
language: en
|
|
`)
|
|
composedPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
|
imports: [workspace.yml]
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
language: en
|
|
`)
|
|
writePipelineSource(t, dir, "workspace.yml", "workspace:\n root: /srv/narratio\n")
|
|
|
|
monolithic, err := LoadPipeline(monolithicPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composed, err := LoadPipeline(composedPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if monolithic.resolution.effectiveDigest == "" || monolithic.resolution.effectiveDigest != composed.resolution.effectiveDigest {
|
|
t.Fatalf("equal effective results have digests %q and %q", monolithic.resolution.effectiveDigest, composed.resolution.effectiveDigest)
|
|
}
|
|
|
|
changedPath := writePipelineSource(t, dir, "changed.yml", `workspace:
|
|
root: /srv/narratio
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
language: fr
|
|
`)
|
|
changed, err := LoadPipeline(changedPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed.resolution.effectiveDigest == monolithic.resolution.effectiveDigest {
|
|
t.Fatal("semantic value change retained effective digest")
|
|
}
|
|
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "secret-one")
|
|
first, err := LoadPipeline(monolithicPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "secret-two")
|
|
second, err := LoadPipeline(monolithicPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.resolution.effectiveDigest != second.resolution.effectiveDigest || strings.Contains(first.resolution.effectiveDigest, "secret") {
|
|
t.Fatalf("environment secret affected or appeared in digest: %q / %q", first.resolution.effectiveDigest, second.resolution.effectiveDigest)
|
|
}
|
|
}
|
|
|
|
func TestEffectivePipelineValuesIgnoreEquivalentSourceLayout(t *testing.T) {
|
|
dir := t.TempDir()
|
|
monolithicPath := writePipelineSource(t, dir, "monolithic.yml", `workspace:
|
|
root: /srv/narratio
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
language: en
|
|
`)
|
|
composedPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
|
imports: [workspace.yml]
|
|
whisperx:
|
|
language: en
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`)
|
|
writePipelineSource(t, dir, "workspace.yml", "workspace:\n root: /srv/narratio\n")
|
|
|
|
monolithic, err := LoadPipeline(monolithicPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composed, err := LoadPipeline(composedPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
monolithicValues, err := EffectivePipelineValues(monolithic)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composedValues, err := EffectivePipelineValues(composed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(monolithicValues, composedValues) {
|
|
t.Fatalf("equivalent effective values differ:\nmonolithic=%#v\ncomposed=%#v", monolithicValues, composedValues)
|
|
}
|
|
}
|
|
|
|
func TestPipelineEffectiveDigestExcludesProfileIdentity(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
|
|
overlay := "workspace:\n root: /srv/narratio\n"
|
|
writePipelineSource(t, dir, "production.yml", overlay)
|
|
writePipelineSource(t, dir, "testing.yml", overlay)
|
|
|
|
production := "production"
|
|
productionCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &production})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testing := "testing"
|
|
testingCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &testing})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if productionCfg.resolution.effectiveDigest != testingCfg.resolution.effectiveDigest {
|
|
t.Fatalf("profile identity changed equal effective digests: %q / %q", productionCfg.resolution.effectiveDigest, testingCfg.resolution.effectiveDigest)
|
|
}
|
|
}
|
|
|
|
func TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, `composition:
|
|
imports: [base.yml]
|
|
default_profile: production
|
|
profiles:
|
|
production:
|
|
overlay: production.yml
|
|
testing:
|
|
overlay: testing.yml
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`)
|
|
writePipelineSource(t, dir, "base.yml", "workspace:\n root: /srv/base\n")
|
|
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /srv/production\nwhisperx:\n language: en\n")
|
|
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /srv/testing\nwhisperx:\n language: fr\n")
|
|
|
|
production, testing, err := LoadPipelineProfilePair(rootPath, "production", "testing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if production.Workspace.Root != "/srv/production" || production.WhisperX.Language != "en" {
|
|
t.Fatalf("production pair result = %#v", production)
|
|
}
|
|
if testing.Workspace.Root != "/srv/testing" || testing.WhisperX.Language != "fr" {
|
|
t.Fatalf("testing pair result = %#v", testing)
|
|
}
|
|
production.Workspace.Root = "/mutated-left"
|
|
if testing.Workspace.Root != "/srv/testing" {
|
|
t.Fatalf("right profile shared mutable state with left: %q", testing.Workspace.Root)
|
|
}
|
|
testingFirst, productionSecond, err := LoadPipelineProfilePair(rootPath, "testing", "production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if testingFirst.Workspace.Root != "/srv/testing" || productionSecond.Workspace.Root != "/srv/production" {
|
|
t.Fatalf("reversed profile pair = testing=%q production=%q", testingFirst.Workspace.Root, productionSecond.Workspace.Root)
|
|
}
|
|
|
|
if _, _, err := LoadPipelineProfilePair(rootPath, "production", "production"); err == nil || !strings.Contains(err.Error(), "must differ") {
|
|
t.Fatalf("equal profile pair error = %v, want selection rejection", err)
|
|
}
|
|
if _, _, err := LoadPipelineProfilePair(rootPath, "unknown", "testing"); err == nil || !strings.Contains(err.Error(), "not declared") {
|
|
t.Fatalf("unknown profile pair error = %v, want selection rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
|
|
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
|
|
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n")
|
|
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n players_file: players.yml\n party_file: party.yml\n")
|
|
writePipelineSource(t, dir, "party.yml", "legacy: party\n")
|
|
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
|
|
selected := "testing"
|
|
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Pipeline.Workspace.Root != "/testing" || cfg.Pipeline.resolution.selectedProfile.source != "cli" {
|
|
t.Fatalf("combined profile result = workspace=%q metadata=%#v", cfg.Pipeline.Workspace.Root, cfg.Pipeline.resolution.selectedProfile)
|
|
}
|
|
}
|
|
|
|
func writeProfilePipeline(t *testing.T, dir, content string) string {
|
|
t.Helper()
|
|
return writePipelineSource(t, dir, "pipeline.yml", content)
|
|
}
|
|
|
|
func profileComposition(defaultProfile string, profiles ...string) string {
|
|
var builder strings.Builder
|
|
builder.WriteString("composition:\n")
|
|
if defaultProfile != "" {
|
|
builder.WriteString(" default_profile: " + defaultProfile + "\n")
|
|
}
|
|
builder.WriteString(" profiles:\n")
|
|
for _, profile := range profiles {
|
|
builder.WriteString(" " + profile + ":\n overlay: " + profile + ".yml\n")
|
|
}
|
|
builder.WriteString("whisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
|
return builder.String()
|
|
}
|
|
|
|
func profilePointer(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func assertSelectedPipelineProfile(t *testing.T, cfg *PipelineConfig, name, source, overlayPath string) {
|
|
t.Helper()
|
|
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil {
|
|
t.Fatal("selected profile provenance is absent")
|
|
}
|
|
selection := cfg.resolution.selectedProfile
|
|
if selection.name != name || selection.source != source || selection.overlayPath != absolutePath(t, overlayPath) {
|
|
t.Fatalf("selected profile = %#v, want name=%q source=%q overlay=%q", selection, name, source, absolutePath(t, overlayPath))
|
|
}
|
|
}
|