From 84b2190fa8079154c02f9edea73ccde85cd777d5 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Wed, 15 Jul 2026 12:32:14 -0400 Subject: [PATCH] Revert "feat(run): accept repeatable --compose-file to layer compose files (#48)" This reverts commit b23bf6810d1ae9d5fe04038491624a9939f8d93f. --- cli/execute.go | 18 +++----- docs/reference/ultra_run.md | 17 +++---- internal/run/run.go | 69 ++++++++++------------------ internal/run/run_test.go | 40 ++-------------- test/integration/integration_test.go | 55 ---------------------- 5 files changed, 38 insertions(+), 161 deletions(-) diff --git a/cli/execute.go b/cli/execute.go index e920f4d..b77b226 100644 --- a/cli/execute.go +++ b/cli/execute.go @@ -69,8 +69,7 @@ func newRootCmd(fc fileConfig) *cobra.Command { func newRunCmd(fc fileConfig) *cobra.Command { shared := &sharedFlags{} - var secretResolver string - var composeFiles []string + var secretResolver, composeFile string cmd := &cobra.Command{ Use: "run [app-path...] --secret-resolver [flags] -- ...", @@ -80,17 +79,12 @@ your command with them set. On every run it regenerates a names-only docker compose override from each app's Config into a temporary directory and points COMPOSE_FILE at it, so docker interpolates the resolved secrets into your containers. The override is derived from the code each time, so it is always -current; no secret value is written to disk. - -Pass --compose-file more than once to layer compose files, like docker's own -f: -they are set on COMPOSE_FILE in order, so a later file (e.g. a gitignored local -override) wins over an earlier one, while the generated secrets override still -applies on top.`, +current; no secret value is written to disk.`, Args: cobra.ArbitraryArgs, } addSharedFlags(cmd, shared) cmd.Flags().StringVar(&secretResolver, "secret-resolver", "", "secret backend: "+resolve.SecretResolverNames()) - cmd.Flags().StringArrayVar(&composeFiles, "compose-file", nil, "docker compose file COMPOSE_FILE points at, relative to --root; repeatable, later files win (default \"docker-compose.yml\")") + cmd.Flags().StringVar(&composeFile, "compose-file", "docker-compose.yml", "base docker compose file COMPOSE_FILE points at, relative to --root") resolverFor := bindSelectedSecretResolver(cmd, fc) cmd.RunE = func(cmd *cobra.Command, args []string) error { @@ -119,9 +113,9 @@ applies on top.`, Composer: compose.NewComposer(), Project: shared.project(), }), - Composer: compose.NewComposer(), - Project: shared.project(), - ComposeFiles: composeFiles, + Composer: compose.NewComposer(), + Project: shared.project(), + ComposeFile: composeFile, }) return runner.Run(cmd.Context(), run.Params{ Apps: apps, diff --git a/docs/reference/ultra_run.md b/docs/reference/ultra_run.md index d1cab7b..3cc1b4c 100644 --- a/docs/reference/ultra_run.md +++ b/docs/reference/ultra_run.md @@ -11,11 +11,6 @@ COMPOSE_FILE at it, so docker interpolates the resolved secrets into your containers. The override is derived from the code each time, so it is always current; no secret value is written to disk. -Pass --compose-file more than once to layer compose files, like docker's own -f: -they are set on COMPOSE_FILE in order, so a later file (e.g. a gitignored local -override) wins over an earlier one, while the generated secrets override still -applies on top. - ``` ultra run [app-path...] --secret-resolver [flags] -- ... ``` @@ -23,12 +18,12 @@ ultra run [app-path...] --secret-resolver [flags] -- ... ### Options ``` - --compose-file stringArray docker compose file COMPOSE_FILE points at, relative to --root; repeatable, later files win (default "docker-compose.yml") - --config-dir string config package directory under each app path (e.g. pkg/config) (default "config") - --config-file string path to the ultra config file (default .ultra.toml under --root) - -h, --help help for run - --root string repo root the compose file and overrides are anchored to (default ".") - --secret-resolver string secret backend: 1password, vault, aws-secret-manager + --compose-file string base docker compose file COMPOSE_FILE points at, relative to --root (default "docker-compose.yml") + --config-dir string config package directory under each app path (e.g. pkg/config) (default "config") + --config-file string path to the ultra config file (default .ultra.toml under --root) + -h, --help help for run + --root string repo root the compose file and overrides are anchored to (default ".") + --secret-resolver string secret backend: 1password, vault, aws-secret-manager ``` ### SEE ALSO diff --git a/internal/run/run.go b/internal/run/run.go index d54b8eb..a89f79c 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -40,34 +40,31 @@ type composer interface { // Runner generates apps' overrides, resolves their secrets, and launches // commands against them. type Runner struct { - generator generator - composer composer - project project.Project - composeFiles []string + generator generator + composer composer + project project.Project + composeFile string } // NewRunnerParams are the dependencies and layout NewRunner needs. type NewRunnerParams struct { - Generator generator - Composer composer - Project project.Project - // ComposeFiles are the base compose files COMPOSE_FILE points at, in order, so - // a later file (e.g. a local override) wins over an earlier one; the generated - // secrets override is always layered last. Empty means "docker-compose.yml". - ComposeFiles []string + Generator generator + Composer composer + Project project.Project + ComposeFile string // base compose file COMPOSE_FILE points at; empty means "docker-compose.yml" } // NewRunner builds a Runner, defaulting the compose file. func NewRunner(params NewRunnerParams) *Runner { - composeFiles := params.ComposeFiles - if len(composeFiles) == 0 { - composeFiles = []string{"docker-compose.yml"} + composeFile := params.ComposeFile + if composeFile == "" { + composeFile = "docker-compose.yml" } return &Runner{ - generator: params.Generator, - composer: params.Composer, - project: params.Project, - composeFiles: composeFiles, + generator: params.Generator, + composer: params.Composer, + project: params.Project, + composeFile: composeFile, } } @@ -152,14 +149,7 @@ func (r *Runner) prepare(ctx context.Context, params Params) (*prepared, error) } env := os.Environ() - // COMPOSE_FILE lists the base files in order, then the generated secrets - // override last. docker merges left to right, so a later base file (a local - // override a developer layers on) wins over an earlier one, while the secrets - // override still applies on top since it maps distinct launcher variables. - composeFiles := make([]string, 0, len(r.composeFiles)+1) - for _, f := range r.composeFiles { - composeFiles = append(composeFiles, filepath.Join(r.project.Root, f)) - } + composeFiles := []string{filepath.Join(r.project.Root, r.composeFile)} if result.Path != "" { composeFiles = append(composeFiles, result.Path) } @@ -175,26 +165,13 @@ func (r *Runner) prepare(ctx context.Context, params Params) (*prepared, error) } // scopeToComposeServices drops any app whose name isn't a service in the base -// compose files, warning for each, so its override block can't turn the merged -// project into one docker rejects. Service names are unioned across every base -// file, since a later file may add a service the first doesn't. It returns the -// apps unchanged when no base file can be read (they may be absent, or the -// command may not be docker), so this only ever removes an app that would -// otherwise have broken the run. +// compose file, warning for each, so its override block can't turn the merged +// project into one docker rejects. It returns the apps unchanged when the base +// compose can't be read (it may be absent, or the command may not be docker), so +// this only ever removes an app that would otherwise have broken the run. func (r *Runner) scopeToComposeServices(apps []string) []string { - services := map[string]bool{} - readAny := false - for _, f := range r.composeFiles { - names, err := compose.ServiceNames(filepath.Join(r.project.Root, f)) - if err != nil { - continue - } - readAny = true - for name := range names { - services[name] = true - } - } - if !readAny { + services, err := compose.ServiceNames(filepath.Join(r.project.Root, r.composeFile)) + if err != nil { return apps } kept := make([]string, 0, len(apps)) @@ -204,7 +181,7 @@ func (r *Runner) scopeToComposeServices(apps []string) []string { kept = append(kept, appPath) continue } - fmt.Fprintf(os.Stderr, "ultra: %s has no service %q in the compose files, skipping it; its secrets won't be injected\n", appPath, name) + fmt.Fprintf(os.Stderr, "ultra: %s has no service %q in %s, skipping it; its secrets won't be injected\n", appPath, name, r.composeFile) } return kept } diff --git a/internal/run/run_test.go b/internal/run/run_test.go index aba8165..aa3f78c 100644 --- a/internal/run/run_test.go +++ b/internal/run/run_test.go @@ -20,16 +20,6 @@ import ( // and composer, so tests exercise the actual override generation run performs on // every invocation. func newTestRunner(root, outputDir, composeFile string, names []string) *Runner { - var composeFiles []string - if composeFile != "" { - composeFiles = []string{composeFile} - } - return newTestRunnerFiles(root, outputDir, composeFiles, names) -} - -// newTestRunnerFiles is newTestRunner with an explicit ordered list of base -// compose files, so tests can assert the layered COMPOSE_FILE ordering. -func newTestRunnerFiles(root, outputDir string, composeFiles, names []string) *Runner { proj := project.Project{Root: root, ConfigDir: "config"} return NewRunner(NewRunnerParams{ Generator: gen.NewGenerator(gen.NewGeneratorParams{ @@ -38,9 +28,9 @@ func newTestRunnerFiles(root, outputDir string, composeFiles, names []string) *R Project: proj, OutputDir: outputDir, }), - Composer: fakeComposer{}, - Project: proj, - ComposeFiles: composeFiles, + Composer: fakeComposer{}, + Project: proj, + ComposeFile: composeFile, }) } @@ -185,30 +175,6 @@ func TestPrepareUsesConfiguredComposeFile(t *testing.T) { assert.Equal(t, filepath.Join(root, "docker-compose.lake.yml"), prep.composeFiles[0]) } -func TestPrepareLayersComposeFilesInOrder(t *testing.T) { - root := t.TempDir() - runner := newTestRunnerFiles( - root, - "", - []string{"docker-compose.yml", "docker-compose.override.yml"}, - []string{"RESOLVED"}, - ) - prep, err := runner.prepare(context.Background(), Params{ - Apps: []string{"app"}, - ResolverFor: resolverFor(map[string]string{"RESOLVED": "value"}), - }) - require.NoError(t, err) - - // Each base file in the order given, then the generated secrets override last - // so a resolved secret is never shadowed by a later base file. - want := []string{ - filepath.Join(root, "docker-compose.yml"), - filepath.Join(root, "docker-compose.override.yml"), - filepath.Join(root, "tmp", "ultra.compose.yml"), - } - assert.Equal(t, want, prep.composeFiles) -} - func TestRunRegeneratesOverride(t *testing.T) { root := t.TempDir() runner := newTestRunner(root, "", "", []string{"RESOLVED"}) diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 36c4e49..1db636f 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -11,7 +11,6 @@ package integration import ( "os" - "path/filepath" "strings" "testing" ) @@ -23,7 +22,6 @@ func TestIntegration(t *testing.T) { r := newRig(t) t.Run("run injects resolved secrets into a container", r.runInjectsResolvedSecrets) - t.Run("run layers a local compose override over the base", r.runLayersComposeOverride) t.Run("run leaves an unresolved secret empty", r.runLeavesUnresolvedSecretEmpty) t.Run("run namespaces a shared secret per app", r.runNamespacesSecretsPerApp) t.Run("validate passes complete and fails on a missing secret", r.validatePassesAndFails) @@ -74,52 +72,6 @@ func (r *Rig) runInjectsResolvedSecrets(t *testing.T) { assertFileContains(t, f.overridePath(), "IT_DB_URL: ${ULTRA_WORKER__IT_DB_URL}") } -func (r *Rig) runLayersComposeOverride(t *testing.T) { - s := r.requireStore(t) - f := r.openFixture(t, "single-app") - t.Cleanup(func() { r.composeDown(f) }) - - if err := s.Seed("worker", map[string]string{ - "IT_DB_URL": "postgres://run-it", - "IT_API_KEY": "run-secret-value", - }); err != nil { - t.Fatal(err) - } - - // A gitignored local override a developer layers on: it changes a non-secret - // config value (LOG_LEVEL, which the base sets to "info") and echoes it back - // alongside the resolved secret, so the run proves both that the override wins - // over the base and that ultra's secret override still applies on top. - override := filepath.Join(f.root, "docker-compose.override.yml") - writeFile(t, override, `services: - worker: - environment: - LOG_LEVEL: from-local-override - command: ["sh", "-c", "echo ULTRASAW LOG_LEVEL=$$LOG_LEVEL IT_API_KEY=$$IT_API_KEY"] -`) - - args := append([]string{ - "run", "apps/worker", "--root", f.root, - "--compose-file", "docker-compose.yml", - "--compose-file", "docker-compose.override.yml", - }, s.addrFlags()...) - args = append(args, "--", "docker", "compose", "up", "--abort-on-container-exit", "--quiet-pull") - res := r.ultra(t, args...) - if !res.ok() { - t.Fatalf("run failed:\n%s", res.output) - } - - // The later compose file wins for the non-secret key. - if !strings.Contains(res.output, "LOG_LEVEL=from-local-override") { - t.Errorf("local override did not win over the base config\n%s", res.output) - } - // The generated secrets override is layered last, so the resolved secret still - // reaches the container even though a base override sits above the base file. - if !strings.Contains(res.output, "IT_API_KEY=run-secret-value") { - t.Errorf("resolved secret was lost when a compose override was layered\n%s", res.output) - } -} - func (r *Rig) runLeavesUnresolvedSecretEmpty(t *testing.T) { s := r.requireStore(t) f := r.openFixture(t, "single-app") @@ -602,13 +554,6 @@ func (r *Rig) runDeliversKitchenSinkSecrets(t *testing.T) { } } -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("writing %s: %v", path, err) - } -} - func assertFileContains(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path)