Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions cli/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [flags] -- <command>...",
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 6 additions & 11 deletions docs/reference/ultra_run.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,19 @@ 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 <name> [flags] -- <command>...
```

### 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
Expand Down
69 changes: 23 additions & 46 deletions internal/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
Expand All @@ -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
}
Expand Down
40 changes: 3 additions & 37 deletions internal/run/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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"})
Expand Down
55 changes: 0 additions & 55 deletions test/integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ package integration

import (
"os"
"path/filepath"
"strings"
"testing"
)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading