Skip to content

Commit 705d984

Browse files
authored
feat(compose): consume network config, preserve source networks, validate refs, and support multi-file encrypted discovery (#66)
- Thread stack.network into GenerateOptions as required networkName; remove hardcoded NETWORK_NAME constant - Preserve non-default merged source networks; config wins for logical default key - Add collectAllServiceNetworkRefs for list and map syntax validation - Reject dangling network references and network_mode plus networks coexistence - Invalid stacks excluded from generated output, disk, render, and deploy - Use Stack "name": reason error format for reload.ts compatibility - Fix stackctl init to write traefik-public default instead of empty string - Change secrets.encryptedFileName to string | string[] for multi-file discovery - Add normalizeEncryptedFileNames with typed result object - Update findEncryptedEnvFiles to accept filename list - Resolve config before discovery in deployPipeline - No-config fallback to [.env.enc] for encrypt/decrypt/clean/check - No-argument encrypt derives plaintext names from configured encrypted names - Fix secrets CLI dispatch to use new Command() pattern for Cliffy compatibility - Map secrets config failures to ExitCode.UserConfigError - Update README, migration.md, codemaps, and AGENTS.md for all behavior changes - 427 tests pass, fmt:check, lint, and type-check clean Closes #65
1 parent edb214f commit 705d984

28 files changed

Lines changed: 1011 additions & 194 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ These are enforced by CI. Match the repo style; the formatter is the authority.
4545
src/
4646
cli/mod.ts — All CLI commands (one large file, ~1600 lines). Uses @cliffy/command.
4747
config/ — Config loading, merging, validation, init. Exports from mod.ts.
48-
compose/ — Docker Compose discovery, generation, merging, transform, reload, sync, plan.
48+
compose/ — Docker Compose discovery, generation, merging, transform, network validation, reload, sync, plan.
4949
render/ — ${VAR} interpolation in compose YAML.
5050
docker/ — Docker / Swarm CLI wrappers (stack deploy, rm, ps, services, swarm status, etc.).
5151
secrets/ — SOPS + age integration for encrypting .env files.

‎README.md‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,17 @@ stack:
150150

151151
With `--profile production`, the resolved config is built from all five files above: `names`
152152
becomes `[web, worker, cron]` (the profile's list replaces the base list, so any base-only
153-
entries are dropped) and `network` ends up as `acme-prod-network` (later layers win).
153+
entries are dropped) and `network` ends up as `acme-prod-network` (later layers win). The resolved
154+
`stack.network` value is consumed by generation as the physical name of the external logical
155+
`default` network. Source-defined non-default networks are preserved, while the config value wins
156+
if a source defines the logical `default` network. `stack.networkDriver` is reserved for future use
157+
and does not affect generated output in this release.
158+
159+
Generation validates service network references against the final top-level declarations. A service
160+
that references an undeclared network produces an error and the stack is not generated, written, or
161+
deployed. A service that declares both `network_mode` (e.g. `host`) and explicit `networks` produces
162+
an error because Docker Compose considers these mutually exclusive. Services using `network_mode`
163+
without explicit `networks` are allowed and skip network validation.
154164

155165
### Precedence
156166

@@ -173,9 +183,10 @@ invocation resolves to.
173183
### Ignoring directories (stack.skipDirectories)
174184

175185
`stack.skipDirectories` is the supported project ignore mechanism. Compose discovery walks the
176-
repository for `docker-compose.yml`/`docker-compose.yaml` files that declare `x-stack` metadata;
177-
any compose file found under a directory whose name is listed here is filtered out of the
178-
discovery result, so it is never generated or deployed:
186+
repository recursively, but only considers files named exactly `docker-compose.yml` or
187+
`docker-compose.yaml`, and only when they declare `x-stack` metadata. Any matching compose file
188+
found under a directory whose name is listed here is filtered out of the discovery result, so it
189+
is never generated or deployed:
179190

180191
```yaml
181192
# .stackctl
@@ -193,6 +204,26 @@ performs discovery (`generate`, `render`, `up`, `down`, `status`, `health`, `log
193204
`reload`, `plan`, `secrets`) honors the setting; `stackctl plan` prints the configured skip
194205
list when one is set.
195206

207+
`stack.directory` is used by `doctor` and `sync` to locate generated stack files. It is not the
208+
generation destination: `generate` writes to `<repoRoot>/stacks` by default, or to the directory
209+
provided with `--output-dir`.
210+
211+
`secrets.encryptedFileName` accepts one encrypted dotenv filename or a list of filenames. Secrets
212+
commands discover all configured filenames recursively, subject to their usual skipped directories.
213+
When no `.stackctl` config file exists, encrypt, decrypt, clean, and check fall back to the default
214+
`[".env.enc"]` list. `secrets deploy` requires a valid `.stackctl` config. When `secrets encrypt`
215+
is run without explicit file arguments, it derives plaintext filenames by stripping `.enc` from
216+
each configured encrypted name (e.g. `.env.enc` -> `.env`, `laravel.env.enc` -> `laravel.env`),
217+
walks for those plaintext files, and encrypts only the ones that do not already have an encrypted
218+
counterpart.
219+
220+
Env status, env audit, and `doctor` encrypted-file checks currently support only `.env.enc` and do
221+
not discover multiple configured encrypted filenames. This is a known limitation planned for a
222+
future release.
223+
224+
The `doctor --fix-volumes` option is currently a stub. It reports that external volume handling is
225+
not yet implemented and does not create missing volumes.
226+
196227
### Override files (--override)
197228

198229
The `--override` flag applies Docker Compose override files to the generated stack data after

‎codemap.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ point.
6060
local overlays, and explicit override files into a validated `ResolvedConfig`.
6161
4. Compose workflows discover source compose files with `x-stack` metadata, load stack fragments,
6262
merge compose data, apply override files, transform service definitions for Swarm compatibility,
63-
collect named volumes, and serialize stack YAML.
63+
preserve non-default source networks while injecting the configured default network, validate
64+
service network references, collect named volumes, and serialize stack YAML.
6465
5. Render workflows parse generated stack YAML, build service-specific interpolation scopes from the
6566
shell environment, `env_file` values, and inline service environment values, then substitute
6667
`${VAR}` and `$VAR` references.

‎docs/migration.md‎

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -78,31 +78,58 @@ Create a `.stackctl` file (generated via `stackctl init`):
7878
project: myproject
7979

8080
stack:
81-
# Root directory for service compose files (each must declare x-stack metadata)
81+
# Directory used by doctor and sync to locate generated stack files
82+
# (generate defaults to <repoRoot>/stacks instead)
8283
directory: ./stack
83-
# Stack names to manage (empty = all discovered)
84-
names: []
84+
# Stack names to manage (must contain at least one name)
85+
names:
86+
- app
8587
# Default Docker network
8688
network: myproject_default
87-
# Override files (profile or explicit)
88-
overrides: []
89+
# Reserved for future use; currently does not affect generated output
90+
networkDriver: overlay
8991

9092
render:
9193
# Output directory for rendered YAML
9294
outputDirectory: ./.rendered
93-
# Fail on unresolved variables
94-
strict: false
95+
96+
secrets:
97+
# One encrypted dotenv filename, or multiple filenames to discover
98+
encryptedFileName: [".env.enc", ".env.production.enc"]
9599
```
96100
97101
### Converting Environment Variables
98102
99-
| Old Environment Variable | New Config Field | Example |
100-
| ------------------------ | ------------------------------------------ | ------------------ |
101-
| `COMPOSE_DIR` | `stack.directory` | `./docker-compose` |
102-
| `RENDER_DIR` | `render.outputDirectory` | `./.rendered` |
103-
| `STACKS_DIR` | No equivalent (generated to `stacks/`) | — |
104-
| `STACK_PREFIX` | `project` | `mystack` |
105-
| `STACKCTL_PROFILE` | `--profile` flag or `STACKCTL_PROFILE` env | `dev` |
103+
| Old Environment Variable | New Config Field | Example |
104+
| ------------------------ | ------------------------------------------ | --------------------- |
105+
| `COMPOSE_DIR` | No direct equivalent | Repository-root scan |
106+
| `RENDER_DIR` | `render.outputDirectory` | `./.rendered` |
107+
| `STACKS_DIR` | No equivalent (generated to `stacks/`) | N/A |
108+
| `STACK_PREFIX` | `project` | `mystack` |
109+
| `STACKCTL_PROFILE` | `--profile` flag or `STACKCTL_PROFILE` env | `dev` |
110+
111+
`stack.directory` is not the source directory for generation. The `generate` command recursively
112+
discovers exact `docker-compose.yml` and `docker-compose.yaml` files from the repository root and
113+
writes generated files to `<repoRoot>/stacks` by default, unless `--output-dir` is provided. The
114+
configured `stack.directory` is used by `doctor` and `sync` when locating generated stack files.
115+
116+
The `secrets.encryptedFileName` field accepts either one encrypted dotenv filename or a list of
117+
filenames. When a list is configured, secrets commands discover all matching files recursively,
118+
subject to the usual skipped directories. When no `.stackctl` config file exists, encrypt, decrypt,
119+
clean, and check fall back to the default `[".env.enc"]` list. `secrets deploy` requires a valid
120+
`.stackctl` config. When `secrets encrypt` is run without explicit file arguments, it derives
121+
plaintext filenames by stripping `.enc` from each configured encrypted name, walks for those
122+
plaintext files, and encrypts only the ones without an existing encrypted counterpart.
123+
124+
Env status, env audit, and `doctor` encrypted-file checks currently support only `.env.enc` and do
125+
not discover multiple configured encrypted filenames. This is a known limitation planned for a
126+
future release.
127+
128+
Generation validates service network references against the final top-level declarations. A service
129+
referencing an undeclared network produces an error and the stack is not generated or deployed. A
130+
service declaring both `network_mode` and explicit `networks` produces an error because Docker
131+
Compose considers these mutually exclusive. Services using `network_mode` without explicit
132+
`networks` are allowed and skip network validation.
106133

107134
## Command Parity
108135

@@ -148,11 +175,11 @@ echo "STACKCTL_PROFILE=${STACKCTL_PROFILE:-dev}"
148175
### Step 2: Run `stackctl init`
149176

150177
```bash
151-
# Interactive detection (scans for docker-compose files)
152-
stackctl init
178+
# Detect repository layout (scans for compose files)
179+
stackctl init --detect
153180
154181
# Or with explicit values
155-
stackctl init --project myproject --preset standard
182+
stackctl init --preset standard
156183
```
157184

158185
This creates `.stackctl` in your project root. Edit it to match your recorded configuration from
@@ -171,6 +198,9 @@ Fixes any issues reported:
171198
- Missing override files
172199
- Missing stack directories
173200

201+
The `doctor --fix-volumes` option is exposed but not yet implemented. It reports this status and
202+
does not create missing external volumes.
203+
174204
### Step 4: Dry-Run a Deployment
175205

176206
```bash
@@ -251,6 +281,11 @@ Override files are applied _after_ profile merging but _before_ render.
251281
named stacks during discovery and generation. Every compose file must declare which
252282
stack it belongs to.
253283

284+
Discovery recursively scans the repository root only for files named exactly
285+
`docker-compose.yml` or `docker-compose.yaml`. Files named `compose.yml`, `compose.yaml`, or
286+
other variants such as `docker-compose.dev.yml` are not discovered by generation. Matching files
287+
must also declare `x-stack` metadata.
288+
254289
### Supported Forms
255290

256291
Two forms are supported:
@@ -364,9 +399,10 @@ docker swarm init
364399
✗ Stack "myapp" not found in /path/to/project
365400
```
366401

367-
Check that your compose files declare `x-stack` metadata and are located within the
368-
configured `stack.directory`. See [Compose Metadata (`x-stack`)](#compose-metadata-x-stack)
369-
for supported forms.
402+
Check that your compose files use the exact names `docker-compose.yml` or `docker-compose.yaml`,
403+
declare `x-stack` metadata, and are located under the repository root. `stack.directory` is used by
404+
`doctor` and `sync` for generated stack files, not as the generation source directory. See
405+
[Compose Metadata (`x-stack`)](#compose-metadata-x-stack) for supported forms.
370406

371407
```yaml
372408
# docker-compose.yml -- either form works:
@@ -386,18 +422,16 @@ Common issues:
386422

387423
- **Missing `project`**: Set the project name in `.stackctl`
388424
- **Missing `stack.network`**: Set the Docker network name
389-
- **Empty `stack.names`**: Leave as `[]` to discover all stacks, or list specific stack names
425+
- **Empty `stack.names`**: Provide at least one stack name; an empty list is rejected
390426
- **Invalid `render.outputDirectory`**: Must be a valid path
391427

392428
### Unresolved Environment Variables
393429

394-
In strict mode (`render.strict: true`), unused variables cause failure. Switch to non-strict mode or
395-
provide the variables:
430+
In strict mode (`stackctl render --strict`), unresolved variables cause failure. Strictness is a
431+
CLI option, not a config field. Run without `--strict` for non-strict mode or provide the
432+
variables:
396433

397434
```bash
398-
# Non-strict mode
399-
echo 'render:\n strict: false' >> .stackctl
400-
401435
# Provide variable
402436
export MY_VAR=value
403437
stackctl up
@@ -452,7 +486,10 @@ This enables drift detection in CI.
452486
### Signal Handling
453487

454488
- **Old**: Ctrl-C may leave processes running
455-
- **New**: SIGINT is forwarded to child processes; `secrets deploy` runs cleanup on interruption
489+
- **New**: Commands that stream child process output (e.g. `up --logs`, `logs`) forward SIGINT
490+
to the child. Most commands use non-streaming execution and do not forward signals. Secrets
491+
cleanup is not automatic after an interrupted deployment; run `stackctl secrets clean` when
492+
needed.
456493

457494
## Using stackctl in GitHub Actions
458495

0 commit comments

Comments
 (0)