From 0be76f8e7c64fb626b1577b5598062e6ec61cf1e Mon Sep 17 00:00:00 2001 From: Frode Sundby Date: Thu, 25 Jun 2026 16:40:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(apply):=20flytt=20fra=20alpha=20til=20rot,?= =?UTF-8?q?=20st=C3=B8tt=20kataloger,=20sorter=20workloads=20sist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Promoter `nais alpha apply` til `nais apply` som top-level kommando (fjerner avhengighet til alpha-pakken) - Støtt katalog som alternativ til enkeltfil: leser alle YAML-filer i katalogen, ignorerer mixin-filer og rendrer hver base med sin miljø-spesifikke mixin - Identifiser mixin-filer (..yaml) ved å matche suffix mot listen av kjente Nais-miljøer fra API-et; fallback til heuristic når API-et ikke er tilgjengelig - Sorter dokumenter slik at workloads (Application, Naisjob) alltid havner sist, så avhengigheter som Config/Valkey/OpenSearch opprettes først - Deaktiver `--set` og `--mixin` for kataloger med tydelig feilmelding --- internal/alpha/command/alpha.go | 2 - internal/application/application.go | 2 + internal/apply/apply.go | 74 ++++++++++- internal/apply/apply_test.go | 129 +++++++++++++++++-- internal/apply/command/apply.go | 16 +-- internal/apply/command/flag/flag.go | 4 +- internal/apply/render.go | 110 ++++++++++++++++ internal/apply/render_test.go | 187 ++++++++++++++++++++++++++++ 8 files changed, 498 insertions(+), 26 deletions(-) diff --git a/internal/alpha/command/alpha.go b/internal/alpha/command/alpha.go index 723548c6..016137af 100644 --- a/internal/alpha/command/alpha.go +++ b/internal/alpha/command/alpha.go @@ -2,7 +2,6 @@ package command import ( "github.com/nais/cli/internal/alpha/command/flag" - apply "github.com/nais/cli/internal/apply/command" "github.com/nais/cli/internal/flags" krakend "github.com/nais/cli/internal/krakend/command" mcpcmd "github.com/nais/cli/internal/mcp/command" @@ -17,7 +16,6 @@ func Alpha(parentFlags *flags.GlobalFlags) *naistrix.Command { Description: "These commands are usually fully functional and ready to use, but the API might evolve based on your feedback.", StickyFlags: flags, SubCommands: []*naistrix.Command{ - apply.Apply(flags), krakend.Krakend(flags), mcpcmd.MCP(flags), }, diff --git a/internal/application/application.go b/internal/application/application.go index dd4ccc76..2c3d90ff 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -10,6 +10,7 @@ import ( activityCommand "github.com/nais/cli/internal/activity/command" alphaCommand "github.com/nais/cli/internal/alpha/command" appCommand "github.com/nais/cli/internal/app/command" + applyCommand "github.com/nais/cli/internal/apply/command" authCommand "github.com/nais/cli/internal/auth/command" configCommand "github.com/nais/cli/internal/config/command" debugCommand "github.com/nais/cli/internal/debug/command" @@ -71,6 +72,7 @@ func New(w io.Writer) (*Application, *flags.GlobalFlags, error) { activityCommand.Activity(globalFlags), alphaCommand.Alpha(globalFlags), appCommand.App(globalFlags), + applyCommand.Apply(globalFlags), authCommand.Auth(globalFlags), configCommand.Config(globalFlags), debugCommand.Debug(globalFlags), diff --git a/internal/apply/apply.go b/internal/apply/apply.go index e2c6d738..4f4c30b8 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -31,11 +32,37 @@ func Run(ctx context.Context, filePath string, flags *flag.Apply, out *naistrix. return err } - data, err := render(filePath, string(flags.Mixin), environment, flags.Set, out) + isDir, err := isDirectory(filePath) if err != nil { return err } + if isDir { + if len(flags.Set) > 0 { + return fmt.Errorf("--set cannot be used when applying a directory (ambiguous target manifest)") + } + if flags.Mixin != "" { + return fmt.Errorf("--mixin cannot be used when applying a directory (mixins are auto-loaded per file)") + } + } + + var data []byte + if isDir { + envs, err := naisapi.GetAllEnvironments(ctx) + if err != nil { + out.Warnf("unable to fetch environment list: %v; mixin files will be identified heuristically\n", err) + } + data, err = renderDir(filePath, environment, envs, out) + if err != nil { + return err + } + } else { + data, err = render(filePath, string(flags.Mixin), environment, flags.Set, out) + if err != nil { + return err + } + } + docs, err := resource.Documents(data) if err != nil { return err @@ -44,6 +71,10 @@ func Run(ctx context.Context, filePath string, flags *flag.Apply, out *naistrix. return fmt.Errorf("no resources found in %s", filePath) } + // Sort documents so workloads (Application, Naisjob) are applied last, + // ensuring their dependencies (e.g. Config, Valkey, OpenSearch) exist first. + sortDocsWorkloadsLast(docs) + var ( crds []unstructured.Unstructured errs []string @@ -340,3 +371,44 @@ func printDryRunYAML(doc *yaml.Node, out *naistrix.OutputWriter) { out.Printf("\n") } } + +// isDirectory reports whether the given path is a directory. +func isDirectory(path string) (bool, error) { + fi, err := os.Stat(path) + if err != nil { + return false, fmt.Errorf("failed to stat %s: %w", path, err) + } + return fi.IsDir(), nil +} + +// sortDocsWorkloadsLast reorders documents so that workload kinds (Application, +// Naisjob) appear after all other resources. This ensures dependencies like +// Config, Valkey, and OpenSearch are created before the workloads that reference +// them. +func sortDocsWorkloadsLast(docs []*yaml.Node) { + sort.SliceStable(docs, func(i, j int) bool { + iIsWorkload := isWorkloadDoc(docs[i]) + jIsWorkload := isWorkloadDoc(docs[j]) + // Non-workloads before workloads; preserve relative order otherwise. + return !iIsWorkload && jIsWorkload + }) +} + +// isWorkloadDoc checks if a YAML document node represents a workload kind. +func isWorkloadDoc(doc *yaml.Node) bool { + kind := docKind(doc) + return workloadKinds[kind] +} + +// docKind extracts the "kind" value from a YAML mapping node. +func docKind(doc *yaml.Node) string { + if doc.Kind != yaml.MappingNode { + return "" + } + for i := 0; i+1 < len(doc.Content); i += 2 { + if doc.Content[i].Value == "kind" { + return doc.Content[i+1].Value + } + } + return "" +} diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index e2a30407..d98a0725 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -10,11 +10,11 @@ import ( "testing" "github.com/google/go-cmp/cmp" - alphaflag "github.com/nais/cli/internal/alpha/command/flag" applyflag "github.com/nais/cli/internal/apply/command/flag" "github.com/nais/cli/internal/apply/resource" flagspkg "github.com/nais/cli/internal/flags" "github.com/nais/naistrix" + "gopkg.in/yaml.v3" ) func TestReadManifestFile_Validation(t *testing.T) { @@ -157,12 +157,10 @@ spec: var out bytes.Buffer flags := &applyflag.Apply{ - Alpha: &alphaflag.Alpha{ - GlobalFlags: &flagspkg.GlobalFlags{ - AdditionalFlags: &flagspkg.AdditionalFlags{ - Team: "my-team", - Environment: "dev", - }, + GlobalFlags: &flagspkg.GlobalFlags{ + AdditionalFlags: &flagspkg.AdditionalFlags{ + Team: "my-team", + Environment: "dev", }, }, DryRun: true, @@ -202,12 +200,10 @@ spec: } flags := &applyflag.Apply{ - Alpha: &alphaflag.Alpha{ - GlobalFlags: &flagspkg.GlobalFlags{ - AdditionalFlags: &flagspkg.AdditionalFlags{ - Team: "my-team", - Environment: "dev", - }, + GlobalFlags: &flagspkg.GlobalFlags{ + AdditionalFlags: &flagspkg.AdditionalFlags{ + Team: "my-team", + Environment: "dev", }, }, DryRun: true, @@ -226,3 +222,110 @@ func mustErrorContains(t *testing.T, err error, want string) { t.Errorf("error %q does not contain %q", err.Error(), want) } } + +func TestSortDocsWorkloadsLast(t *testing.T) { + mkDoc := func(kind string) *yaml.Node { + return &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "kind"}, + {Kind: yaml.ScalarNode, Value: kind}, + }, + } + } + + docs := []*yaml.Node{ + mkDoc("Application"), + mkDoc("Valkey"), + mkDoc("Naisjob"), + mkDoc("OpenSearch"), + mkDoc("Config"), + } + + sortDocsWorkloadsLast(docs) + + kinds := make([]string, len(docs)) + for i, d := range docs { + kinds[i] = docKind(d) + } + + // Workloads should be at the end + for i, kind := range kinds { + if workloadKinds[kind] && i < 3 { + t.Errorf("workload %q at index %d, expected at index >= 3", kind, i) + } + if !workloadKinds[kind] && i >= 3 { + t.Errorf("non-workload %q at index %d, expected at index < 3", kind, i) + } + } + + // Non-workloads preserve relative order + nonWorkloads := []string{} + for _, k := range kinds { + if !workloadKinds[k] { + nonWorkloads = append(nonWorkloads, k) + } + } + if want := []string{"Valkey", "OpenSearch", "Config"}; strings.Join(nonWorkloads, ",") != strings.Join(want, ",") { + t.Errorf("non-workload order = %v, want %v", nonWorkloads, want) + } + + // Workloads preserve relative order + workloads := []string{} + for _, k := range kinds { + if workloadKinds[k] { + workloads = append(workloads, k) + } + } + if want := []string{"Application", "Naisjob"}; strings.Join(workloads, ",") != strings.Join(want, ",") { + t.Errorf("workload order = %v, want %v", workloads, want) + } +} + +func TestRun_DirectoryWithSetFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "manifests") + if err := os.Mkdir(manifestPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestPath, "app.yaml"), []byte("kind: Application\nmetadata:\n name: myapp\nspec:\n image: test\n"), 0o600); err != nil { + t.Fatal(err) + } + + flags := &applyflag.Apply{ + GlobalFlags: &flagspkg.GlobalFlags{ + AdditionalFlags: &flagspkg.AdditionalFlags{ + Team: "my-team", + Environment: "dev", + }, + }, + Set: []string{"spec.image=override"}, + } + + err := Run(context.Background(), manifestPath, flags, naistrix.NewOutputWriter(io.Discard, new(naistrix.Count))) + mustErrorContains(t, err, "--set cannot be used when applying a directory") +} + +func TestRun_DirectoryWithMixinFlagFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "manifests") + if err := os.Mkdir(manifestPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestPath, "app.yaml"), []byte("kind: Application\nmetadata:\n name: myapp\nspec:\n image: test\n"), 0o600); err != nil { + t.Fatal(err) + } + + flags := &applyflag.Apply{ + GlobalFlags: &flagspkg.GlobalFlags{ + AdditionalFlags: &flagspkg.AdditionalFlags{ + Team: "my-team", + Environment: "dev", + }, + }, + Mixin: "some-mixin.yaml", + } + + err := Run(context.Background(), manifestPath, flags, naistrix.NewOutputWriter(io.Discard, new(naistrix.Count))) + mustErrorContains(t, err, "--mixin cannot be used when applying a directory") +} diff --git a/internal/apply/command/apply.go b/internal/apply/command/apply.go index a3a8f622..650e4ce9 100644 --- a/internal/apply/command/apply.go +++ b/internal/apply/command/apply.go @@ -5,35 +5,35 @@ import ( "fmt" "time" - alpha "github.com/nais/cli/internal/alpha/command/flag" "github.com/nais/cli/internal/apply" "github.com/nais/cli/internal/apply/command/flag" + "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/validation" "github.com/nais/naistrix" ) -func Apply(parentFlags *alpha.Alpha) *naistrix.Command { - flags := &flag.Apply{Alpha: parentFlags, Timeout: 10 * time.Minute} +func Apply(parentFlags *flags.GlobalFlags) *naistrix.Command { + flags := &flag.Apply{GlobalFlags: parentFlags, Timeout: 10 * time.Minute} return &naistrix.Command{ Name: "apply", Title: "Apply resources.", - Description: "Apply a Nais resource manifest (YAML) to a specific team and environment. The manifest file, team, and environment are required.", + Description: "Apply Nais resource manifests (YAML) to a specific team and environment. Accepts a single file or a directory containing multiple manifests. When a directory is given, mixin files (..yaml) are auto-loaded and --set/--mixin flags are disabled.", Args: []naistrix.Argument{ - {Name: "file"}, + {Name: "path"}, }, AutoCompleteExtensions: []string{"yaml", "yml"}, Flags: flags, ValidateFunc: naistrix.ValidateFuncs( validation.RequireTeam(flags), func(ctx context.Context, args *naistrix.Arguments) error { - if args.Get("file") == "" { - return fmt.Errorf("file cannot be empty") + if args.Get("path") == "" { + return fmt.Errorf("path cannot be empty") } return nil }, ), RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return apply.Run(ctx, args.Get("file"), flags, out) + return apply.Run(ctx, args.Get("path"), flags, out) }, } } diff --git a/internal/apply/command/flag/flag.go b/internal/apply/command/flag/flag.go index 714315cf..d76509a7 100644 --- a/internal/apply/command/flag/flag.go +++ b/internal/apply/command/flag/flag.go @@ -3,12 +3,12 @@ package flag import ( "time" - alpha "github.com/nais/cli/internal/alpha/command/flag" + "github.com/nais/cli/internal/flags" "github.com/nais/naistrix" ) type Apply struct { - *alpha.Alpha + *flags.GlobalFlags AllowIgnoredFields bool `name:"allow-ignored-fields" usage:"Warn instead of failing when a manifest contains fields that nais apply ignores (e.g. |metadata.namespace| or |metadata.annotations|)."` DryRun bool `name:"dry-run" usage:"Preview which resources would be applied without making any changes."` Mixin mixinFile `name:"mixin" usage:"YAML |FILE| deep-merged over the base manifest (mixin values win). If omitted, an adjacent ..yaml is auto-loaded when present."` diff --git a/internal/apply/render.go b/internal/apply/render.go index 016368b0..f6721e79 100644 --- a/internal/apply/render.go +++ b/internal/apply/render.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/nais/naistrix" @@ -142,3 +143,112 @@ func decodeSingleDocument(data []byte, path string) (map[string]any, error) { return doc, nil } + +// renderDir collects all YAML base files in a directory (excluding mixin files), +// renders each with its environment-specific mixin (if present), and returns the +// concatenated YAML output. +// +// knownEnvs is an optional list of known Nais environment names used to identify +// mixin files: a file "..yaml" is treated as a mixin when the +// corresponding base file exists and env is a known environment. When knownEnvs +// is empty or nil, any "..yaml" file with a matching base is +// treated as a mixin (heuristic fallback). +func renderDir(dirPath, environment string, knownEnvs []string, out *naistrix.OutputWriter) ([]byte, error) { + entries, err := os.ReadDir(dirPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dirPath, err) + } + + // Collect all YAML files and identify which are mixins. + var yamlFiles []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := filepath.Ext(name) + if ext != ".yaml" && ext != ".yml" { + continue + } + yamlFiles = append(yamlFiles, name) + } + + mixins := mixinSet(yamlFiles, knownEnvs) + + // Collect base files (non-mixins) sorted alphabetically. + var baseFiles []string + for _, name := range yamlFiles { + if mixins[name] { + continue + } + baseFiles = append(baseFiles, name) + } + sort.Strings(baseFiles) + + if len(baseFiles) == 0 { + return nil, fmt.Errorf("no YAML resource files found in %s", dirPath) + } + + var combined []byte + for _, name := range baseFiles { + basePath := filepath.Join(dirPath, name) + data, err := render(basePath, "", environment, nil, out) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if len(combined) > 0 { + combined = append(combined, []byte("---\n")...) + } + combined = append(combined, data...) + if len(data) > 0 && data[len(data)-1] != '\n' { + combined = append(combined, '\n') + } + } + + return combined, nil +} + +// mixinSet returns the set of filenames that are environment-specific mixins. +// A file is considered a mixin when it matches the pattern ".." +// and the corresponding base file "." exists in the directory. +// +// When knownEnvs is non-empty, only suffixes present in the list are considered +// environment names; otherwise any suffix is accepted (heuristic fallback for +// when the environment list could not be fetched). +func mixinSet(yamlFiles []string, knownEnvs []string) map[string]bool { + fileSet := make(map[string]bool, len(yamlFiles)) + for _, f := range yamlFiles { + fileSet[f] = true + } + + envSet := make(map[string]bool, len(knownEnvs)) + for _, e := range knownEnvs { + envSet[e] = true + } + + mixins := make(map[string]bool) + + for _, name := range yamlFiles { + ext := filepath.Ext(name) + stem := name[:len(name)-len(ext)] + + before, after, ok := strings.Cut(stem, ".") + if !ok { + continue + } + + baseName := before + ext + if !fileSet[baseName] { + continue + } + + suffix := after + if len(envSet) > 0 && !envSet[suffix] { + continue + } + + mixins[name] = true + } + + return mixins +} diff --git a/internal/apply/render_test.go b/internal/apply/render_test.go index 44b0880b..8ab64448 100644 --- a/internal/apply/render_test.go +++ b/internal/apply/render_test.go @@ -136,3 +136,190 @@ func TestRender_UnsupportedExtensionFails(t *testing.T) { t.Errorf("got %v, want error containing %q", err, "unsupported file extension") } } + +func TestRenderDir_CollectsBaseFilesAndExcludesMixins(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n") + writeFile(t, dir, "app.dev.yaml", "spec:\n image: dev-image\n") + writeFile(t, dir, "valkey.yaml", "kind: Valkey\nmetadata:\n name: myvalkey\n") + + got, err := renderDir(dir, "dev", nil, discardWriter()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := string(got) + // app.yaml should be rendered with its mixin + if !strings.Contains(output, "dev-image") { + t.Errorf("output should contain mixin value 'dev-image', got:\n%s", output) + } + // valkey.yaml should be included + if !strings.Contains(output, "Valkey") { + t.Errorf("output should contain Valkey resource, got:\n%s", output) + } + // mixin file should NOT appear as its own resource + if strings.Count(output, "kind:") != 2 { + t.Errorf("expected 2 resources, got:\n%s", output) + } +} + +func TestRenderDir_ExcludesMixinsForAllEnvironments(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.yaml", "apiVersion: nais.io/v1alpha1\nkind: Application\nmetadata:\n name: myapp\nspec:\n image: base\n") + writeFile(t, dir, "app.dev-gcp.yaml", "spec:\n image: dev\n") + writeFile(t, dir, "app.prod-gcp.yaml", "spec:\n image: prod\n") + writeFile(t, dir, "config.yaml", "version: v1\nkind: Config\nmetadata:\n name: myconfig\ndata:\n KEY: value\n") + writeFile(t, dir, "config.dev-gcp.yaml", "data:\n ENV: dev\n") + writeFile(t, dir, "config.prod-gcp.yaml", "data:\n ENV: prod\n") + + got, err := renderDir(dir, "dev-gcp", nil, discardWriter()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := string(got) + // Only base files should produce resources + if strings.Count(output, "kind:") != 2 { + t.Errorf("expected 2 resources (app + config), got:\n%s", output) + } + // The active mixin for dev-gcp should be merged + if !strings.Contains(output, "dev") { + t.Errorf("expected dev-gcp mixin to be applied, got:\n%s", output) + } +} + +func TestRenderDir_IgnoresNonYAMLFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n") + writeFile(t, dir, "readme.md", "# Not a manifest") + writeFile(t, dir, "config.json", "{}") + + got, err := renderDir(dir, "dev", nil, discardWriter()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(got), "Application") { + t.Errorf("expected Application in output, got:\n%s", got) + } +} + +func TestRenderDir_EmptyDirectoryFails(t *testing.T) { + dir := t.TempDir() + + _, err := renderDir(dir, "dev", nil, discardWriter()) + if err == nil || !strings.Contains(err.Error(), "no YAML resource files found") { + t.Errorf("got %v, want error about no YAML files", err) + } +} + +func TestRenderDir_OnlyMixinFilesFails(t *testing.T) { + dir := t.TempDir() + // Create a mixin without a corresponding base - but since there's no base, + // it won't be detected as a mixin and will be treated as a base file. + // To actually test "only mixins", we need a base that has a mixin. + writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n") + writeFile(t, dir, "app.dev.yaml", "spec:\n image: dev\n") + + got, err := renderDir(dir, "dev", nil, discardWriter()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should only render app.yaml (with mixin), not treat mixin as separate resource + if strings.Count(string(got), "kind:") != 1 { + t.Errorf("expected 1 resource (mixin excluded), got:\n%s", got) + } +} + +func TestRenderDir_IgnoresSubdirectories(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n") + subdir := filepath.Join(dir, "subdir") + if err := os.Mkdir(subdir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, subdir, "other.yaml", "kind: Other\nmetadata:\n name: other\n") + + got, err := renderDir(dir, "dev", nil, discardWriter()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(string(got), "Other") { + t.Errorf("subdirectory files should not be included, got:\n%s", got) + } +} + +func TestMixinSet(t *testing.T) { + t.Run("known environments", func(t *testing.T) { + files := []string{"app.yaml", "app.dev.yaml", "app.prod.yaml", "valkey.yaml", "valkey.dev.yaml", "standalone.yaml"} + envs := []string{"dev", "prod"} + + mixins := mixinSet(files, envs) + if !mixins["app.dev.yaml"] { + t.Error("app.dev.yaml should be a mixin") + } + if !mixins["app.prod.yaml"] { + t.Error("app.prod.yaml should be a mixin") + } + if !mixins["valkey.dev.yaml"] { + t.Error("valkey.dev.yaml should be a mixin") + } + if mixins["app.yaml"] { + t.Error("app.yaml should NOT be a mixin") + } + if mixins["valkey.yaml"] { + t.Error("valkey.yaml should NOT be a mixin") + } + if mixins["standalone.yaml"] { + t.Error("standalone.yaml should NOT be a mixin") + } + }) + + t.Run("unknown environment suffix is not a mixin", func(t *testing.T) { + files := []string{"app.yaml", "app.unknown.yaml", "config.yaml", "config.unknown.yaml"} + envs := []string{"dev", "prod"} + + mixins := mixinSet(files, envs) + if mixins["app.unknown.yaml"] { + t.Error("app.unknown.yaml should NOT be a mixin ('unknown' is not a known environment)") + } + }) + + t.Run("mixin without base is not filtered", func(t *testing.T) { + files := []string{"orphan.dev.yaml", "other.yaml"} + envs := []string{"dev"} + + mixins := mixinSet(files, envs) + if mixins["orphan.dev.yaml"] { + t.Error("orphan.dev.yaml should NOT be a mixin (no orphan.yaml base)") + } + }) + + t.Run("compound environment names", func(t *testing.T) { + files := []string{"app.yaml", "app.dev-gcp.yaml", "app.prod-gcp.yaml", "config.yaml", "config.dev-gcp.yaml"} + envs := []string{"dev-gcp", "prod-gcp"} + + mixins := mixinSet(files, envs) + if !mixins["app.dev-gcp.yaml"] { + t.Error("app.dev-gcp.yaml should be a mixin") + } + if !mixins["app.prod-gcp.yaml"] { + t.Error("app.prod-gcp.yaml should be a mixin") + } + if !mixins["config.dev-gcp.yaml"] { + t.Error("config.dev-gcp.yaml should be a mixin") + } + }) + + t.Run("fallback heuristic with empty env list", func(t *testing.T) { + files := []string{"app.yaml", "app.dev.yaml", "config.yaml", "config.prod.yaml"} + envs := []string{} + + mixins := mixinSet(files, envs) + if !mixins["app.dev.yaml"] { + t.Error("app.dev.yaml should be a mixin (heuristic fallback)") + } + if !mixins["config.prod.yaml"] { + t.Error("config.prod.yaml should be a mixin (heuristic fallback)") + } + }) +}