From b940679f83ea82cc7ed4191d0ad3b83c261178ce Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 27 Aug 2026 18:15:49 +0200 Subject: [PATCH] docs: register every environment variable the binary reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~25 recognized variables were scattered across five constant files plus the compose-go-handled ones, with 6 documented; whether the project .env can set a given variable depended on an unwritten fact (the moment it is read: flag-default time, after setEnvWithDotEnv injection, or from project.Environment). docs/envvars.md is now the single registry — name, read site, moment, default — including the known divergences (COMPOSE_REMOVE_ORPHANS per-command inconsistency, dead COMPOSE_EXPERIMENTAL/COMPOSE_BAKE) so the map stays truthful rather than aspirational. A test sweeps production sources for COMPOSE_* literals and fails when one is missing from the registry. Epic #14074, section D. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- AGENTS.md | 9 ++++ docs/envvars.md | 97 +++++++++++++++++++++++++++++++++++ docs/envvars_registry_test.go | 76 +++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 docs/envvars.md create mode 100644 docs/envvars_registry_test.go diff --git a/AGENTS.md b/AGENTS.md index a0f73f6825..ac0c2a2b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,15 @@ - In tests: use `t.Context()` instead of `context.Background()` or `context.TODO()` - Prefer `fmt.Fprintf` over `WriteString(fmt.Sprintf(...))` +## Environment variables + +- Every environment variable the binary reads is registered in + `docs/envvars.md` (name, read site, moment, default) — consult it before + adding a read, and update it in the same commit when you do + (`docs/envvars_registry_test.go` fails otherwise). The moment a variable is + read decides whether the project `.env` can set it; the registry explains + the three moments. + ## Git - **All commits MUST be signed off (DCO)**. Always pass `--signoff` (`-s`) to `git commit` and `git commit --amend`. diff --git a/docs/envvars.md b/docs/envvars.md new file mode 100644 index 0000000000..721498ce8e --- /dev/null +++ b/docs/envvars.md @@ -0,0 +1,97 @@ +# Environment variables Compose reads + +Single registry of every environment variable the `docker compose` binary +itself recognizes: where it is read, at which moment, and its default. The +user-facing subset is documented on docs.docker.com; this file is the +exhaustive, code-accurate map, kept next to the code so a change to a read +site updates it in the same commit. + +Variables consumed by services (interpolation in the compose file, `env_file`, +`environment:`) are out of scope — they belong to the model, not the binary. +So are the variables the docker CLI itself handles (`DOCKER_HOST`, +`DOCKER_CONTEXT`, `DOCKER_CONFIG`, TLS settings, …): compose inherits them +through `command.Cli` without reading them. + +## When a variable is read — the three moments that decide what can set it + +1. **Flag-default time** — evaluated while the cobra command tree is built, + before any file is read. Only the actual process environment works; the + project's `.env` can never influence these. +2. **After `.env` injection** — `setEnvWithDotEnv` + (`cmd/compose/compose.go`) runs early in `PersistentPreRunE` and exports + into the process environment every `COMPOSE_*` key found in the project's + env files that is not already set in the process environment. Variables + read after that point (still via `os.Getenv`/`os.LookupEnv`) can therefore + come from the shell **or** from the project `.env` — shell wins. Caveat: + the injection is skipped entirely for remote (OCI/Git) configs. +3. **Project environment** — read from `project.Environment` (the merged + os-env ∪ env-files mapping compose-go builds, os wins) after the project + is loaded. Same effective precedence as (2), but per-command: only + commands that load a project get a value. + +A variable read at flag-default time and never re-read cannot be set from the +project `.env` — that asymmetry is a recurring source of bug reports; when +touching a read site, prefer moments (2) or (3). + +## Project selection and loading + +| Variable | Equivalent flag | Read | Default / values | +|---|---|---|---| +| `COMPOSE_FILE` | `-f` | compose-go (`cli.WithConfigFileEnv`), moment 3-ish: honored from shell and project `.env` | default file discovery (`compose.yaml`…) | +| `COMPOSE_PATH_SEPARATOR` | — | compose-go, splits `COMPOSE_FILE` | `:` (unix), `;` (windows) | +| `COMPOSE_PROJECT_NAME` | `-p` | `os.Getenv` in `projectOrName`/`toProjectName` (`cmd/compose/compose.go`), moment 2 | derived from project directory name | +| `COMPOSE_PROFILES` | `--profile` | compose-go | none | +| `COMPOSE_ENV_FILES` | `--env-file` | flag default (moment 1 — cannot come from `.env`, by construction) | `.env` in project dir | +| `COMPOSE_DISABLE_ENV_FILE` | — | compose-go | `false` | +| `COMPOSE_CONVERT_WINDOWS_PATHS` | — | compose-go (volume paths in the model) | `false` | +| `COMPOSE_COMPATIBILITY` | `--compatibility` | project environment (moment 3, `cmd/compose/compose.go` + `pkg/compose/loader.go`) | `false` | + +## Runtime behavior + +| Variable | Equivalent flag | Read | Default / values | +|---|---|---|---| +| `COMPOSE_PARALLEL_LIMIT` | `--parallel` | `resolveMaxConcurrency`, moment 2; flag wins | `-1` (unlimited) | +| `COMPOSE_ANSI` | `--ansi` | `resolveAnsiMode`, moment 2; flag wins. Also checked directly in `cmd/compose/hooks.go` for OSC8 hyperlinks | `auto` / `never` / `always` | +| `COMPOSE_PROGRESS` | `--progress` | flag default (moment 1 — cannot come from `.env`) | `auto` / `tty` / `plain` / `json` / `quiet` | +| `COMPOSE_STATUS_STDOUT` | — | package `init()` (moment 1) | `false`; `true` routes status/progress output to stdout instead of stderr | +| `COMPOSE_MENU` | `--menu` | `up` only, moment 2; flag wins | `true` when attached to a TTY | +| `COMPOSE_IGNORE_ORPHANS` | — | project environment (moment 3) — by `up` and `run` only; `create` shares the options struct but reads neither orphan variable | `false` | +| `COMPOSE_REMOVE_ORPHANS` | `--remove-orphans` | inconsistent (see #14139): `up` reads it at moment 2, `down`/`kill` at moment 1 (so the project `.env` works for `up` but not for `down`/`kill`), `run` never reads it | `false` | +| `DOCKER_DEFAULT_PLATFORM` | — | project environment (moment 3), resolved into `service.Platform` by `applyPlatforms` (`cmd/compose/options.go`) for every container-creating command — the value feeds the service config-hash, so commands MUST resolve it identically or containers spuriously recreate | none | + +## Build + +| Variable | Read | Default / values | +|---|---|---| +| `BUILDX_BUILDER` | classic (non-bake) build path picks the buildx builder by name (`cmd/compose/build.go`) | current buildx builder | +| `BUILDKIT_PROGRESS` | bake path, fallback progress mode when compose's own mode is `auto` (`pkg/compose/build_bake.go`) | — | + +## Watch + +| Variable | Read | Default / values | +|---|---|---| +| `COMPOSE_WATCH_WINDOWS_BUFFER_SIZE` | `pkg/watch/notify.go`, Windows only | `65536` | + +## Remote configs + +| Variable | Read | Default / values | +|---|---|---| +| `COMPOSE_EXPERIMENTAL_GIT_REMOTE` | `pkg/remote/git.go`, process env | `true` — despite the EXPERIMENTAL name (kept for backward compatibility) this is an opt-out: set a falsy value to disable | +| `COMPOSE_EXPERIMENTAL_OCI_REMOTE` | `pkg/remote/oci.go`, process env | `true` — same opt-out semantics as its git sibling | + +## Observability + +| Variable | Read | Default / values | +|---|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` (and standard `OTEL_*` family) | `internal/tracing`, standard OpenTelemetry SDK resolution; a Docker-context-declared OTLP endpoint is used as an additional exporter | disabled | +| `NO_COLOR` | `cmd/compose/compose.go`, `cmd/compose/hooks.go` (moment 2); any non-empty value | color enabled | + +## Historical — recognized only to tell you they do nothing + +`COMPOSE_EXPERIMENTAL` (unwired feature-flag mechanism) and `COMPOSE_BAKE` +(bake is simply the default build path) are gone: no code reads them, setting +them has no effect. + +| Variable | Status | +|---|---| +| `COMPOSE_EXPERIMENTAL_WATCH_TAR` | read by `pkg/compose/watch.go` only to warn that a falsy value is ignored — the tar-based synchronization is the only implementation | diff --git a/docs/envvars_registry_test.go b/docs/envvars_registry_test.go new file mode 100644 index 0000000000..3d7a0cba76 --- /dev/null +++ b/docs/envvars_registry_test.go @@ -0,0 +1,76 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package docs + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +var composeVarLiteral = regexp.MustCompile(`"(COMPOSE_[A-Z_0-9]+)"`) + +// Every COMPOSE_* variable named in production code must have a row in +// docs/envvars.md — the registry is only useful while it is exhaustive, and +// nothing but this test keeps it so. e2e sources are excluded: they set +// variables to exercise them, they don't define new ones. +func TestEnvVarRegistryIsExhaustive(t *testing.T) { + registry, err := os.ReadFile("envvars.md") + assert.NilError(t, err) + + inCode := map[string][]string{} + for _, root := range []string{"../cmd", "../pkg", "../internal"} { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "e2e" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + for _, m := range composeVarLiteral.FindAllStringSubmatch(string(src), -1) { + name := m[1] + if name == "COMPOSE_" { // prefix checks, not variables + continue + } + inCode[name] = append(inCode[name], path) + } + return nil + }) + assert.NilError(t, err) + } + assert.Assert(t, len(inCode) > 10, "the sweep found suspiciously few variables — did the source layout move?") + + for name, sites := range inCode { + assert.Assert(t, strings.Contains(string(registry), "`"+name+"`"), + "%s is read in code (%s) but has no row in docs/envvars.md — add it (or remove the dead read)", name, strings.Join(sites, ", ")) + } +}