detached 25270e4

feat: Gordon v2.50.0 application deployment model (#276)

* feat(apps): add declarative application deployments

* fix(security): enforce host trust boundaries

* fix(security): close remaining host trust gaps

* test(security): align hardened image fixtures

* refactor(cli): route local commands through admin socket

* fix(admin): authorize local control plane routes

* feat(install): build next channel from pinned source

* feat(install): default to user-local binary path

* fix(ci): avoid overlapping Go module caches

* fix(apps): address wrap-up review findings

* fix(apps): close v2.50 deferred findings (#280)

* fix(apps): persist mutation identity and close state cleanly

* refactor(cli): unify app control plane capabilities

* fix(apps): claim mutation keys atomically

Add a durable request identity to app operation journals and one atomic
store claim that binds an idempotency key to exactly one request:

- domain.AppOperation carries the immutable request identity (kind, app,
  requested revision, requested service).
- out.AppState.ClaimOperation persists the in-flight journal for an
  absent key, replays the stored journal for the same request, and
  refuses a key that already answered a different request with
  ErrAppStateConflict.
- A claim for a name with no live app identity fails with the new
  ErrAppNotFound and writes nothing; the admin API maps it to 404
  app-not-found. read-only AppExists backs the not-found normalization
  for revisions.
- Every keyed deploy/stop/start/restart/remove claims before any effect.
  A replayed key returns its stored journal and runs no workload call; an
  unfinished journal returns the stored record with a conflict.
- The apps use case returns the journaled operation together with the
  engine error instead of dropping the record.

* fix(apps): order operation steps behind publication and complete read models

Journal ordering:

- A deploy service step is recorded as succeeded only after the runtime
  effect, the ACTIVE publication, and the traffic graph apply succeed;
  a rejected apply fails the step instead of leaving a success behind.
- Lifecycle verbs record a failed traffic.publish step and a failed
  outcome when the final graph apply is rejected, so a rejected
  publication never replays as success.
- Every error path after the claim now records a terminal failure, so a
  keyed repeat replays the failure instead of reading a stuck claim.
- start marks a service step succeeded only after its effective state is
  published.

Read models:

- Show returns not-found for a name with no live app identity, and
  reports desired acceptance/pending status, per-service effective
  revision, container, digest, restart safety, owned resources
  (volumes, secret paths, image references), and the latest operation
  with kind, outcome, and start time.
- List skips retired names and reports desired status, pending,
  convergence, stop intent, and the last outcome. Pending is derived
  from desired vs ACTIVE, never from a journal.
- Admin DTO mappings carry these fields; deploy responses attach current
  effective and owned state instead of inferring convergence from the
  journal outcome. The unused observed field is gone.

* fix(app): publish the host index only after the graph apply succeeds

Traffic publication now prepares and commits instead of mutating the
live host index first:

- ACTIVE state projects into a detached candidate index that the graph
  is built and applied against. The live index is swapped only after the
  dataplane accepted the graph, so a rejected apply leaves both the
  index and the graph at the previous generation.
- A failing projection applies nothing at all.
- Withdrawal stays fail-closed: when the graph apply fails after the
  durable binds were cleared, the read model still drops the withdrawn
  host so the proxy never dials a recycled loopback port; the error is
  returned unchanged so the caller keeps its publication inhibition.

HostIndex gains an Entries accessor for the swap, and the publisher
takes its graph applier as a wired dependency so the ordering is
testable without a live traffic manager.

* fix(runtime): retire exact containers with the effective stop grace

Runtime contract:

- StopContainer and RestartContainer take the effective stop grace. The
  Docker adapter converts it to whole API seconds, rounding up, and keeps
  the runtime default for a non-positive grace instead of an immediate
  kill. The fixed twenty-second timeout is gone.
- Every app path passes the grace of the generation it stops: stop,
  restart, removal, replacement retirement, readiness-failure cleanup,
  and recovery cleanup. A missing declared grace falls back to the app
  default, never to zero.

One retirement path:

- retireContainer stops with grace (or force-removes a never-published
  candidate), confirms the exact ID is gone, and only then releases the
  container's backend claims and clears its recovery inhibition. A
  container that is not confirmed gone keeps its claims, its inhibition,
  and its ACTIVE reference, and fails the operation.
- Stop, remove, deploy service removal, HTTP replacement retirement,
  interrupted replacement, readiness-failure candidate cleanup, create
  and start cleanup, backend-bind registration failure, and
  container-not-found recovery all use it, so no disappearance path leaks
  a loopback claim.
- A confirmed-missing container releases its claims during recovery.

Bounded leftovers:

- Journaled operations carry bounded cleanup warnings (service, leftover,
  detail) for anything that could not be removed or released; they reach
  the admin response instead of being dropped, and never contain logs.

* fix(apps): reclaim private app networks on removal

Removal now removes the app's incarnation-owned private networks
immediately, once every exact container is confirmed gone and while the
ownership record is still live:

- The runtime name is re-derived from the incarnation UUID and the
  observed labels must prove that exact ownership, so a name reused by a
  later incarnation can never delete the previous one's network.
- A network with attached containers, or with labels that do not prove
  this incarnation, is left in place and reported as a bounded warning.
- A failed removal aborts the operation before the incarnation is
  retired, so the stopped intent, the inhibition, and ACTIVE state stay
  and a retry can converge.
- Shared networks are never removed by an app removal, and retained
  volumes, secrets, and images are untouched.

* refactor(backup): resolve app backups from manifest declarations

Backup targets are now read from the app's ACTIVE record instead of being
inferred, and the app name is the backup identity:

- A target exists because a service declares it: a PostgreSQL database in
  service.databases referenced by service.backup.postgres, or a volume in
  service.volumes referenced by service.backup.volume. The image, port,
  container label, and attachment heuristics are gone, along with the
  detection endpoint and DTOs that exposed them.
- Selectors are explicit: an omitted --service or resource selector only
  succeeds when exactly one compatible target exists, ambiguity lists the
  candidate service/resource names, and an unknown target is not found.
- The scheduler runs only databases whose own declared schedule matches
  the firing tier, and volume runs follow the installation preset.
- Artifacts are stored under the app name: filesystem paths and S3 keys
  use apps/<app>/..., never a domain prefix. Existing domain-keyed
  artifacts are left untouched and are not read.
- The admin API and CLI rename domain fields to app, add service, and
  name the resource database or volume. The CLI gains
  `backup run APP --service S --database D` and
  `backup volume run APP --service S --volume V`; the domain-keyed
  `detect` command and the databases/volumes aliases are gone.

* refactor(cli): unify the app and daemon control planes

The CLI now has one ControlPlane interface and one daemon-backed
implementation:

- App methods moved into ControlPlane; AppControlPlane,
  remoteAppControlPlane, and app_controlplane.go are gone, and
  remoteControlPlane delegates every operation to a single remote.Client
  (explicit remote or owner-only local admin socket).
- App commands resolve through resolveAppPlane, which returns a handle
  they close, instead of a separate resolver with no ownership.
- CLI tests drive the generated MockControlPlane: the handwritten
  fakeAppPlane and status shim are gone, and the source-text parity and
  UI adoption checks are replaced by the behavior they guarded (local
  socket and explicit-remote resolution, DTO parity across transports,
  presentation helpers).

* refactor: drop dead app plumbing and test-only production hooks

- AppServiceImpl keeps one configured core apps Service instead of
  rebuilding it on every apply; the listener, barrier, and image-policy
  options configure that instance in place.
- Remove the container cache/drain/metrics hooks that only stored their
  dependencies, the proxy container-deployed invalidation handler and its
  event payload, the container event constants nothing published or read,
  the internal-deploy context flag with no reader, the label constants
  the declarative cutover stopped using, and the deprecated revision
  retention alias.
- Move the app-state corruption hooks out of the production store into
  export_test.go.

* docs: describe current backup, log, and app identity behavior

- Backup docs document declarative targets (app, service, database or
  volume), the explicit selector rules, app-keyed artifact paths, and the
  app-based JSON fields.
- App docs describe the idempotency contract, the complete show read
  model, and app/service-only log identity.
- `gordon logs` reads daemon process logs only: the domain-keyed
  container-log path and its control-plane methods are gone, and app
  workload output is read through `gordon apps logs`.

* fix(apps): close deferred review gaps
commit
25270e4106ff3d0ed99d9afd3c588dcb9bb31553
author
bnema · · 2026-09-12 14:48 UTC
committer
GitHub · · 2026-09-12 14:48 UTC
parent
da9b3d2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
439 files · +44645 -50814
M .github/workflows/ci.yml +19 -24
@@ -13,6 +13,7 @@ jobs:
1313 pull-requests: read
1414 outputs:
1515 go: ${{ steps.filter.outputs.go }}
16+ installer: ${{ steps.filter.outputs.installer }}
1617 steps:
1718 - uses: actions/checkout@v6
1819 - uses: dorny/paths-filter@v3
@@ -26,6 +27,22 @@ jobs:
2627 - '.golangci.yml'
2728 - 'Makefile'
2829 - '.github/workflows/**'
30+ installer:
31+ - 'install.sh'
32+ - 'tests/install_next_test.sh'
33+ - '.github/workflows/ci.yml'
34+
35+ installer:
36+ name: Installer
37+ needs: changes
38+ if: needs.changes.outputs.installer == 'true'
39+ runs-on: ubuntu-latest
40+ steps:
41+ - uses: actions/checkout@v6
42+ - name: Check installer scripts
43+ run: |
44+ shellcheck -s sh install.sh tests/install_next_test.sh
45+ sh tests/install_next_test.sh
2946
3047 lint:
3148 name: Lint
@@ -38,14 +55,7 @@ jobs:
3855 - uses: actions/setup-go@v5
3956 with:
4057 go-version: "1.27.x"
41-
42- - name: Cache Go modules
43- uses: actions/cache@v5
44- with:
45- path: ~/go/pkg/mod
46- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
47- restore-keys: |
48- ${{ runner.os }}-go-mod-
58+ cache: true
4959
5060 - name: Run golangci-lint
5161 uses: golangci/golangci-lint-action@v9
@@ -63,22 +73,7 @@ jobs:
6373 - uses: actions/setup-go@v5
6474 with:
6575 go-version: "1.27.x"
66-
67- - name: Cache Go modules
68- uses: actions/cache@v5
69- with:
70- path: ~/go/pkg/mod
71- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
72- restore-keys: |
73- ${{ runner.os }}-go-mod-
74-
75- - name: Cache Go build cache
76- uses: actions/cache@v5
77- with:
78- path: ~/.cache/go-build
79- key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
80- restore-keys: |
81- ${{ runner.os }}-go-build-
76+ cache: true
8277
8378 - name: Run tests
8479 run: go test -race -v ./...
M .mockery.yaml +11 -3
@@ -19,18 +19,25 @@ packages:
1919 SecretProvider:
2020 EnvLoader:
2121 ContainerLogWriter:
22- AttachmentConfigProvider:
2322 TokenStore:
23+ AppState:
24+ AppStateReader:
25+ ImageResolver:
26+ SecretWriter:
2427 DomainSecretStore:
2528 RateLimiter:
2629 BackupStorage:
27- RouteChecker:
2830 HTTPChallengeSink:
2931 PublicCertificateIssuer:
3032 CertificateStore:
3133 SecretResolver:
3234 CloudflareZoneResolver:
3335 CertificateAuthority:
36+ AppTrafficRefresher:
37+ PruneProtectionStore:
38+ PruneRuntime:
39+ GCBarrier:
40+ GCLease:
3441 github.com/bnema/gordon/internal/boundaries/in:
3542 interfaces:
3643 ContainerService:
@@ -47,7 +54,8 @@ packages:
4754 VolumeService:
4855 PublicTLSService:
4956 TrafficStatusService:
50- StandaloneServiceService:
57+ AppService:
58+ AppReconciler:
5159 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
5260 # Mocked here because it abstracts Docker SDK calls that require a running
5361 # daemon, making unit/integration tests impractical without a test double.
M README.md +73 -45
@@ -2,7 +2,7 @@
22
33 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
44
5-Self-hosted container deployment. Push an image, Gordon routes it to the web.
5+Self-hosted container deployment. Push an image, declare an app, deploy it.
66
77 - Website: https://bnema.dev/gordon
88 - Documentation: [Docs](https://gordon.bnema.dev/docs) | [Wiki](https://gordon.bnema.dev/wiki)
@@ -12,47 +12,70 @@ Self-hosted container deployment. Push an image, Gordon routes it to the web.
1212
1313 ## What is Gordon?
1414
15-Gordon is a private container registry and HTTP reverse proxy for your VPS. Push a container image that exposes a web port — Gordon deploys it with zero downtime.
15+Gordon is a private container registry, an app runtime, and a reverse proxy for your VPS.
16+
17+The flow is explicit: build and push an image, declare the app in a standalone TOML file, apply it, then deploy. Push transfers OCI content only — it never deploys.
1618
1719 ## Quick Start
1820
1921 ```bash
20-# Install
22+# Install the latest stable release to ~/.local/bin
2123 curl -fsSL https://gordon.bnema.dev/install.sh | sh
2224
23-# Start the server
25+# Start the server (restart your shell first if the installer updated PATH)
2426 gordon serve
2527 ```
2628
29+The installer uses `~/.local/bin` without `sudo` and can add the effective install directory to Fish, Bash, or Zsh PATH configuration. Set `GORDON_UPDATE_PATH=1` to update PATH without prompting or `GORDON_UPDATE_PATH=0` to leave configuration unchanged. Override the destination with an absolute path such as `GORDON_INSTALL_DIR="$HOME/bin"`, `GORDON_INSTALL_DIR="$HOME/.local/bin"`, or `GORDON_INSTALL_DIR=/usr/local/bin`.
30+
31+To build the current `next` branch commit locally, use `GORDON_CHANNEL=next`. This is an unverified development source build, not a checksum-verified release, and requires a compatible Go toolchain. See the [installation guide](https://gordon.bnema.dev/docs/installation#choosing-an-install-channel).
32+
2733 Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for full setup.
2834
2935 ## Deploy with the CLI
3036
31-Build locally, push directly to your Gordon server:
37+Build locally, push to your Gordon server, declare the app, deploy:
3238
3339 ```bash
34-# Push using an explicit domain override
35-gordon push myapp:latest --domain app.example.com
40+# Push the image (OCI transfer only, never deploys)
41+gordon push myapp --build --remote prod
42+
43+# Declare the app (blog.toml references the pushed tag)
44+gordon apps apply --file blog.toml --remote prod
3645
37-# Or add a route manually, then deploy
38-gordon routes add app.example.com myapp:latest
39-gordon routes deploy app.example.com
46+# Activate it
47+gordon apps deploy blog --remote prod
4048
4149 # Check status
4250 gordon status
51+gordon apps status blog --remote prod
4352 ```
4453
45-Roll back, restart, or manage secrets — all from the command line:
54+Minimal app file (`blog.toml`):
55+
56+```toml
57+name = "blog"
58+
59+[[service]]
60+name = "web"
61+image = "gordon.mydomain.com/myapp:v1.2.0"
62+
63+[[service.http]]
64+host = "blog.mydomain.com"
65+port = 3000
66+```
67+
68+Manage lifecycle and secrets — all from the command line:
4669
4770 ```bash
48-gordon pin app.example.com # Pin to a specific image tag
49-gordon restart app.example.com # Restart the container
50-gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123
71+gordon apps restart blog --remote prod # Restart from pinned digests
72+gordon apps stop blog --remote prod # Stop, preserve all data
73+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
5174 ```
5275
5376 ## Deploy from CI/CD
5477
55-Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on image push.
78+Push to Gordon's registry from any CI pipeline, then apply and deploy with the CLI. Push never triggers a deploy by itself.
5679
5780 ### GitHub Actions
5881
@@ -64,17 +87,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on
6487 password: ${{ secrets.GORDON_TOKEN }}
6588 ```
6689
67-### Docker CLI
90+See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
91+
92+### Docker CLI + Gordon CLI
6893
6994 ```bash
70-docker login registry.mydomain.com
71-docker build -t registry.mydomain.com/myapp:v1.0.0 .
72-docker push registry.mydomain.com/myapp:v1.0.0
73-# -> Deployed automatically
95+docker login gordon.mydomain.com
96+docker build -t gordon.mydomain.com/myapp:v1.0.0 .
97+docker push gordon.mydomain.com/myapp:v1.0.0
98+# Then, with the Gordon binary:
99+gordon apps apply --file blog.toml --remote prod
100+gordon apps deploy blog --remote prod
74101 ```
75102
76-See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
77-
78103 ## CLI Commands
79104
80105 ### Server
@@ -82,20 +107,24 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
82107 | Command | Description |
83108 |---------|-------------|
84109 | `gordon serve` | Start the Gordon server |
85-| `gordon status` | Show server and route health |
86-| `gordon config show` | Display server configuration |
110+| `gordon status` | Show server and app fleet status |
111+| `gordon config show` | Display installation configuration |
87112
88-### Deployment
113+### Applications
89114
90115 | Command | Description |
91116 |---------|-------------|
92-| `gordon push [image]` | Tag, push, and optionally deploy an image |
93-| `gordon routes list` | List all routes |
94-| `gordon routes add <domain> <image>` | Create or update a route |
95-| `gordon routes remove <domain>` | Remove a route |
96-| `gordon routes deploy <domain>` | Redeploy a route |
97-| `gordon pin <domain>` | Pin a route to a specific image tag |
98-| `gordon restart <domain>` | Restart a route container |
117+| `gordon apps apply --file FILE` | Validate and persist an app manifest |
118+| `gordon apps deploy APP` | Activate an app revision |
119+| `gordon apps list` | List applications |
120+| `gordon apps show APP` | Show desired and active state |
121+| `gordon apps status APP` | Show effective vs observed state |
122+| `gordon apps logs APP` | Show logs for an app service |
123+| `gordon apps restart APP` | Restart from pinned digests |
124+| `gordon apps stop APP` | Stop, preserve all data |
125+| `gordon apps start APP` | Start a stopped app |
126+| `gordon apps remove APP` | Remove workloads (volumes/secrets retained) |
127+| `gordon push [image]` | Tag and push an image (never deploys) |
99128
100129 ### Images & Registry
101130
@@ -109,9 +138,9 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
109138
110139 | Command | Description |
111140 |---------|-------------|
112-| `gordon secrets list <domain>` | List secrets for a route |
113-| `gordon secrets set <domain> KEY=VAL` | Set secrets |
114-| `gordon secrets remove <domain> <key>` | Remove a secret |
141+| `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |
142+| `gordon secrets list <domain>` | List installation secret keys |
143+| `gordon secrets set <domain> --from-file PATH` | Set installation secrets |
115144
116145 ### Remotes & Auth
117146
@@ -125,19 +154,18 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
125154
126155 ## Features
127156
128-- Private Docker registry on your VPS
157+- Private Docker/Podman registry on your VPS
158+- Declarative apps: one TOML file per app, explicit apply then deploy
129159 - Domain-to-container routing through a smart TCP edge reverse proxy
130-- Automatic deployment on image push
131-- Auto-routing from image labels
132-- Remote CLI management
133-- Zero downtime updates
134-- Persistent volumes from Dockerfile VOLUME directives
135-- Environment variable management with secrets support
136-- Network isolation per application
137-- Single binary, ~15MB RAM
160+- HTTP zero-downtime updates (old container serves until replacement passes readiness)
161+- Remote CLI management (daemon is the sole writer)
162+- Declarative per-service volumes, retained across lifecycle operations
163+- Per-service secrets in pass, app-wide public env in the manifest
164+- Per-app private networks plus opt-in shared networks
165+- Single binary
138166
139167 > [!NOTE]
140-> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
168+> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements. Use Gordon's ownership-aware volume commands; runtime commands such as `docker volume prune` bypass Gordon's retention checks.
141169
142170 ## Documentation
143171
A docs/cli/apps.md +213 -0
@@ -0,0 +1,213 @@
1+# Apps Commands
2+
3+Validate, persist, inspect, and operate applications.
4+
5+All mutations are executed by the daemon through the admin API.
6+Without a reachable daemon the commands fail with `daemon-unavailable`
7+instead of writing locally. Mutations are idempotent: every request carries a
8+client-generated key, and the daemon binds that key to the exact request.
9+Repeating a key replays the recorded result; reusing it for a different request
10+is refused. An interrupted operation is never executed twice, so an ambiguous
11+outcome is re-queried by key before any retry (never retry under a fresh
12+key). Unknown apps are reported as `app not found` and create no state.
13+
14+## Local and remote targets
15+
16+With no `--remote`/`GORDON_REMOTE` selected, commands discover the daemon's
17+owner-only administration socket. The CLI checks `$XDG_RUNTIME_DIR/gordon/admin.sock`
18+when set, then `/run/user/<uid>/gordon/admin.sock`, then `~/.gordon/run/admin.sock`.
19+It accepts only a safe owner-owned socket. This works with `auth.enabled=false`:
20+the socket carries no bearer token and grants the `local-owner` principal only
21+app administration and app log reads. An explicit remote is authoritative and
22+never falls back to the socket.
23+See [Local-only Mode](../config/auth.md#local-only-mode).
24+
25+## gordon apps
26+
27+### Subcommands
28+
29+| Subcommand | Description |
30+|------------|-------------|
31+| `apply` | Validate and persist an app manifest |
32+| `list` | List applications |
33+| `show` | Show desired and active state for an app |
34+| `diff` | Show the normalized desired-vs-active diff |
35+| `secrets` | Manage app secret values |
36+| `deploy` | Activate an app revision |
37+| `restart` | Restart an app from pinned digests |
38+| `stop` | Stop an app (preserves all data) |
39+| `start` | Start a stopped app from active state |
40+| `remove` | Remove app workloads (volumes and secrets are retained) |
41+| `status` | Show effective vs observed state for an app |
42+| `logs` | Show logs for an app service |
43+
44+---
45+
46+## gordon apps apply
47+
48+```bash
49+gordon apps apply --file blog.toml [--dry-run] [--deploy] [--json]
50+```
51+
52+Validates a manifest file and persists it as desired state (or dry-runs).
53+With `--deploy`, chains exactly the accepted revision into a deploy after
54+persistence succeeds; the two outcomes are reported separately because a
55+deploy may fail after the apply succeeded.
56+
57+`--dry-run` and `--deploy` cannot be combined.
58+
59+---
60+
61+## gordon apps list
62+
63+```bash
64+gordon apps list [--json]
65+```
66+
67+Lists applications with one row per app. States are `applied` when no revision is active, `pending` when desired state awaits activation, `stopped` when stopped intent is set, and otherwise `active`. A non-success last operation is appended to `active` in parentheses.
68+
69+---
70+
71+## gordon apps show
72+
73+```bash
74+gordon apps show APP [--json]
75+```
76+
77+Shows desired revision and acceptance status, pending state, per-service
78+effective revision, container, digest, and restart safety, the resources the
79+app owns (volumes, secret paths, image references — never secret values),
80+stopped intent, and the last operation with its outcome.
81+
82+---
83+
84+## gordon apps diff
85+
86+```bash
87+gordon apps diff APP [--json]
88+```
89+
90+Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`).
91+
92+---
93+
94+## gordon apps secrets
95+
96+Values are accepted via `KEY=VALUE` arguments (discouraged: shell history),
97+`--stdin` (preferred), or an interactive prompt. Names must already exist in
98+desired or active state. Only key names are ever echoed back — never values.
99+
100+```bash
101+gordon apps secrets set APP --service SVC KEY=VALUE… [--stdin] [--json]
102+gordon apps secrets delete APP KEY --service SVC [--json]
103+```
104+
105+`--service` is required: secrets are service-scoped.
106+
107+---
108+
109+## gordon apps deploy
110+
111+```bash
112+gordon apps deploy APP [--revision REV] [--service SVC] [--json]
113+```
114+
115+Activates a revision (default: desired head). Fail-fast across services:
116+the first failure stops the deploy, successful services are preserved,
117+later services stay unchanged.
118+
119+---
120+
121+## gordon apps restart
122+
123+```bash
124+gordon apps restart APP [--service SVC] [--json]
125+```
126+
127+Restarts from pinned digests without re-resolution.
128+
129+---
130+
131+## gordon apps stop
132+
133+```bash
134+gordon apps stop APP [--json]
135+```
136+
137+Persists the durable stopped intent and stops exact containers.
138+All data (volumes, secrets) is preserved. Stopped apps stay stopped
139+across reboot.
140+
141+---
142+
143+## gordon apps start
144+
145+```bash
146+gordon apps start APP [--json]
147+```
148+
149+Clears the stopped intent and ensures running from active state.
150+
151+---
152+
153+## gordon apps remove
154+
155+```bash
156+gordon apps remove APP [--json]
157+```
158+
159+Withdraws workloads. Volumes and secrets are retained as owned orphans
160+under the old internal UUID; removing frees the name but never implicitly
161+attaches retained resources to a new app reusing the name.
162+
163+There is deliberately no `purge`: destructive volume deletion requires a
164+separately accepted destructive-action contract.
165+
166+---
167+
168+## gordon apps status
169+
170+```bash
171+gordon apps status APP [--json]
172+```
173+
174+Shows effective vs observed state per service.
175+
176+---
177+
178+## gordon apps logs
179+
180+```bash
181+gordon apps logs APP [--service SVC] [--follow] [--tail N] [--json]
182+```
183+
184+Streams logs for a service in the app's active deployment. The app name and
185+`--service` are the only accepted identity: domains and raw container IDs are
186+never accepted. `--service` is required when the app has several services.
187+
188+---
189+
190+## Workflow Example
191+
192+```bash
193+# Push the image first (OCI transfer only)
194+gordon push myapp --build --remote prod
195+
196+# Apply the manifest that references the pushed tag
197+gordon apps apply --file blog.toml --remote prod
198+
199+# Write secret values for registered names
200+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
201+
202+# Deploy the accepted revision
203+gordon apps deploy blog --remote prod
204+
205+# Inspect
206+gordon apps show blog --remote prod
207+gordon apps status blog --remote prod
208+```
209+
210+## Related
211+
212+- [CLI Overview](./index.md)
213+- [Push Command](./push.md)
D docs/cli/attachments.md +0 -349
@@ -1,349 +0,0 @@
1-# Attachments Commands
2-
3-Manage attachments on local or remote Gordon instances.
4-
5-Remote targeting uses client config or an active remote by default.
6-When you provide a concrete target (for example `attachments list app.example.com`
7-or `attachments remove app.example.com postgres:18`) and no remote is selected,
8-Gordon can also auto-infer a saved remote when exactly one match is found.
9-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
10-
11-## Requirements
12-
13-Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
14-
15-The CLI will warn you if you try to add an attachment without network isolation enabled.
16-
17-## gordon attachments
18-
19-### Subcommands
20-
21-| Subcommand | Description |
22-|------------|-------------|
23-| `list` | List all attachments or attachments for a specific target |
24-| `add` | Add an attachment to a domain or network group |
25-| `push` | Build or push attachment images to the Gordon registry |
26-| `remove` | Remove an attachment from a domain or network group |
27-| `orphans` | List running attachment containers no longer configured |
28-| `prune` | Dry-run or stop orphaned attachment containers while preserving volumes |
29-
30-### Alias
31-
32-`gordon attach` is an alias for `gordon attachments`:
33-
34-```bash
35-gordon attach list
36-gordon attach add app.example.com postgres:18
37-gordon attach remove app.example.com postgres:18
38-```
39-
40----
41-
42-## gordon attachments list
43-
44-List all configured attachments.
45-
46-```bash
47-# List all attachments
48-gordon attachments list
49-gordon attachments list --json
50-
51-# List attachments for a specific domain or group
52-gordon attachments list app.example.com
53-gordon attachments list backend
54-
55-# Remote (override)
56-gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
57-```
58-
59-### Output
60-
61-```
62-Target Attachments
63---------------------------------------------------------------------------------
64-app.example.com postgres:18, redis:7-alpine
65-api.example.com postgres:18
66-backend (group) rabbitmq:3-management
67-```
68-
69-### JSON Output
70-
71-```bash
72-gordon attachments list --json
73-```
74-
75-```json
76-[
77- {
78- "target": "app.example.com",
79- "attachments": ["postgres:18", "redis:7-alpine"]
80- },
81- {
82- "target": "backend",
83- "attachments": ["rabbitmq:3-management"]
84- }
85-]
86-```
87-
88-### Options
89-
90-| Option | Description |
91-|--------|-------------|
92-| `--json` | Output attachments as JSON |
93-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
94-| `--token` | Authentication token for remote |
95-
96----
97-
98-## gordon attachments orphans
99-
100-List running attachment containers that are no longer configured.
101-
102-```bash
103-gordon attachments orphans
104-gordon attachments orphans --json
105-```
106-
107-### Output
108-
109-| Container ID | Name | Image | Status | Owner |
110-|--------------|------|-------|--------|-------|
111-| `pg123` | `postgres` | `postgres:16` | `running` | `myapp.example.com` |
112-
113-### JSON Output
114-
115-```json
116-{
117- "attachments": [
118- {
119- "container_id": "pg123",
120- "name": "postgres",
121- "image": "postgres:16",
122- "status": "running",
123- "owner": "myapp.example.com"
124- }
125- ]
126-}
127-```
128-
129-## gordon attachments prune
130-
131-Lists orphaned attachments by default (dry-run). Use `--stop` to stop and remove the orphaned containers while preserving attachment volumes/data.
132-
133-```bash
134-gordon attachments prune # dry-run/report only
135-gordon attachments prune --stop # stop/remove orphaned containers, preserve volumes
136-```
137-
138-### Output
139-
140-```text
141-Orphaned attachments
142- postgres -> postgres:16
143-Hint: orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes
144-```
145-
146-### Output with `--stop`
147-
148-```text
149-Removed orphaned attachment containers:
150- postgres
151-Hint: attachment volumes and data were preserved
152-```
153-
154-### Options
155-
156-| Option | Description |
157-|--------|-------------|
158-| `--stop` | Stop and remove orphaned attachment containers while preserving volumes/data |
159-| `--json` | Output the cleanup report as JSON |
160-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
161-| `--token` | Authentication token for remote |
162-
163-## gordon attachments add
164-
165-Add an attachment to a domain or network group.
166-
167-```bash
168-gordon attachments add <domain-or-group> <image>
169-gordon attachments add app.example.com postgres:18
170-gordon attachments add backend redis:7-alpine
171-```
172-
173-### Arguments
174-
175-| Argument | Description |
176-|----------|-------------|
177-| `<domain-or-group>` | The domain name or network group name |
178-| `<image>` | The container image to attach |
179-
180-### Options
181-
182-| Option | Description |
183-|--------|-------------|
184-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
185-| `--token` | Authentication token for remote |
186-
187-### Examples
188-
189-```bash
190-# Add database to a domain
191-gordon attachments add app.example.com postgres:18
192-
193-# Add cache to a network group (shared by all domains in the group)
194-gordon attachments add backend redis:7-alpine
195-
196-# Remote (override)
197-gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
198-```
199-
200----
201-
202-## gordon attachments push
203-
204-Push attachment images to the Gordon registry.
205-
206-```bash
207-gordon attachments push <image> [options]
208-```
209-
210-### Arguments
211-
212-| Argument | Description |
213-|----------|-------------|
214-| `<image>` | The attachment image to build/tag and push |
215-
216-### Options
217-
218-| Option | Description |
219-|--------|-------------|
220-| `--build` | Build the image first using `docker buildx` |
221-| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
222-| `--platform` | Target platform for buildx (default: `linux/amd64`) |
223-| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
224-| `--tag` | Override version tag |
225-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
226-| `--token` | Authentication token for remote |
227-
228-### Description
229-
230-`gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first.
231-
232-It uses the same native chunked upload transport as `gordon push`, sending image
233-layers in 50MB chunks so pushes work through Cloudflare-proxied Gordon
234-instances. Keep the server's `max_blob_chunk_size` larger than the client chunk
235-size; the default `95MB` is compatible.
236-
237-### Examples
238-
239-```bash
240-# Push a pre-built attachment image
241-gordon attachments push pitlane-pgsql
242-
243-# Build and push an attachment image
244-gordon attachments push pitlane-pgsql --build
245-
246-# Push with a specific tag
247-gordon attachments push pitlane-pgsql --tag v18
248-```
249-
250----
251-
252-## gordon attachments remove
253-
254-Remove an attachment.
255-
256-```bash
257-gordon attachments remove <domain-or-group> <image>
258-gordon attachments remove app.example.com postgres:18
259-```
260-
261-### Arguments
262-
263-| Argument | Description |
264-|----------|-------------|
265-| `<domain-or-group>` | The domain name or network group name |
266-| `<image>` | The container image to remove |
267-
268-### Options
269-
270-| Option | Description |
271-|--------|-------------|
272-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
273-| `--token` | Authentication token for remote |
274-
275-### Examples
276-
277-```bash
278-# Remove database from a domain
279-gordon attachments remove app.example.com postgres:18
280-
281-# Remove from network group
282-gordon attachments remove backend redis:7-alpine
283-
284-# Remote (override)
285-gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
286-```
287-
288----
289-
290-## Workflow Examples
291-
292-### Add Database and Cache
293-
294-```bash
295-# Add PostgreSQL database
296-gordon attachments add app.example.com postgres:18
297-
298-# Add Redis cache
299-gordon attachments add app.example.com redis:7-alpine
300-
301-# Verify
302-gordon attachments list app.example.com
303-```
304-
305-### Shared Services via Network Groups
306-
307-```bash
308-# First, ensure you have a network group defined in your config:
309-# [network_groups]
310-# "backend" = ["app.example.com", "api.example.com"]
311-
312-# Add shared cache to the group
313-gordon attachments add backend redis:7-alpine
314-
315-# Both app.example.com and api.example.com can now access the shared Redis
316-```
317-
318-### First Deploy with Custom Attachment Image
319-
320-```bash
321-# Configure the route and attachment
322-gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql
323-
324-# Push the custom attachment image first
325-gordon attachments push pitlane-pgsql --build
326-
327-# Then push and deploy the route image
328-gordon push myapp:latest --domain app.example.com --build --no-confirm
329-```
330-
331-### CI/CD Integration
332-
333-```bash
334-# In your CI/CD pipeline
335-export GORDON_REMOTE=https://gordon.mydomain.com
336-export GORDON_TOKEN=$GORDON_TOKEN
337-
338-# Add new attachment
339-gordon attachments add app.example.com elasticsearch:8
340-
341-# Trigger redeploy to pick up the new attachment
342-gordon routes deploy app.example.com
343-```
344-
345-## Related
346-
347-- [CLI Overview](./index.md)
348-- [Attachments Configuration](../config/attachments.md)
349-- [Network Groups](../config/network-groups.md)
M docs/cli/auth.md +3 -3
@@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
135135
136136 Registry scopes: `push`, `pull`, `push,pull`
137137
138-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`
138+Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`
139139
140140 Combine scopes with commas:
141141
@@ -271,8 +271,8 @@ Usage:
271271 | `pull` | Pull images from registry |
272272 | `push,pull` | Both push and pull (default) |
273273 | `admin:*:*` | Full admin access |
274-| `admin:routes:read` | Read-only routes access |
275-| `admin:routes:write` | Routes write access |
274+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
275+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
276276 | `admin:config:read` | Read-only config access |
277277 | `admin:config:write` | Config write access |
278278 | `admin:status:read` | Read-only status/health |
D docs/cli/autoroute.md +0 -205
@@ -1,205 +0,0 @@
1-# Auto-Route Commands
2-
3-Manage the auto-route domain allowlist on local or remote Gordon instances.
4-
5-When auto-route is enabled, Gordon handles image pushes in two ways:
6-
7-1. **Image-name matching** — when a pushed image matches an existing route's configured image, Gordon auto-deploys that route. Since the route already exists, no allowlist check is needed.
8-2. **Label-based creation** — when a pushed image carries `gordon.domain` labels, Gordon creates new routes for those domains. The domain allowlist restricts which domains may be created this way, preventing untrusted images from registering arbitrary domains.
9-
10-The allowlist gates only the creation of new routes from labels. Routes created manually with `gordon routes add` or already present in configuration are not subject to the allowlist.
11-
12-Remote targeting uses client config or an active remote by default.
13-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
14-
15-## gordon autoroute
16-
17-Manage auto-route settings.
18-
19-### Subcommands
20-
21-| Subcommand | Description |
22-|------------|-------------|
23-| `allow` | Manage auto-route allowed domains |
24-
25----
26-
27-## gordon autoroute allow
28-
29-Manage auto-route allowed domains.
30-
31-### Subcommands
32-
33-| Subcommand | Description |
34-|------------|-------------|
35-| `add` | Add a domain pattern to the allowlist |
36-| `list` | List allowed domains |
37-| `remove` | Remove a domain pattern |
38-
39----
40-
41-## gordon autoroute allow add
42-
43-Add a domain pattern to the auto-route allowlist.
44-
45-```bash
46-gordon autoroute allow add <pattern>
47-gordon autoroute allow add example.com
48-gordon autoroute allow add "*.staging.example.com"
49-gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
50-```
51-
52-### Arguments
53-
54-| Argument | Description |
55-|----------|-------------|
56-| `<pattern>` | Domain pattern to allow for auto-route |
57-
58-### Options
59-
60-| Option | Description |
61-|--------|-------------|
62-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
63-| `--token` | Authentication token for remote |
64-
65-### Description
66-
67-Patterns must be lowercase and must not have trailing dots.
68-A bare `*` matches all domains. Wildcards in `*.domain.tld` form match exactly one subdomain level, so `*.example.com` matches `app.example.com` but not `api.app.example.com`.
69-
70-### Examples
71-
72-```bash
73-# Allow an exact domain
74-gordon autoroute allow add example.com
75-
76-# Allow one subdomain level under staging.example.com
77-gordon autoroute allow add "*.staging.example.com"
78-
79-# Remote (override)
80-gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
81-```
82-
83-### Output
84-
85-```text
86-✓ Allowed domain added
87-```
88-
89----
90-
91-## gordon autoroute allow list
92-
93-List auto-route allowed domains.
94-
95-```bash
96-gordon autoroute allow list
97-gordon autoroute allow list --json
98-gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
99-```
100-
101-### Options
102-
103-| Option | Description |
104-|--------|-------------|
105-| `--json` | Output allowed domains as JSON |
106-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
107-| `--token` | Authentication token for remote |
108-
109-### Description
110-
111-Shows the current allowlist used to restrict which domains auto-route may claim.
112-When no patterns are configured, the command prints a friendly empty-state message instead of a table.
113-
114-### Examples
115-
116-```bash
117-# Local
118-gordon autoroute allow list
119-
120-# JSON
121-gordon autoroute allow list --json
122-
123-# Remote (override)
124-gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
125-```
126-
127-### Output
128-
129-```text
130-example.com
131-*.staging.example.com
132-```
133-
134-When the allowlist is empty:
135-
136-```text
137-No allowed domains configured
138-```
139-
140-### JSON Output
141-
142-```bash
143-gordon autoroute allow list --json
144-```
145-
146-```json
147-{
148- "domains": ["example.com", "*.staging.example.com"]
149-}
150-```
151-
152----
153-
154-## gordon autoroute allow remove
155-
156-Remove a domain pattern from the auto-route allowlist.
157-
158-```bash
159-gordon autoroute allow remove <pattern>
160-gordon autoroute allow remove example.com
161-gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
162-```
163-
164-### Arguments
165-
166-| Argument | Description |
167-|----------|-------------|
168-| `<pattern>` | Domain pattern to remove from the allowlist |
169-
170-### Options
171-
172-| Option | Description |
173-|--------|-------------|
174-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
175-| `--token` | Authentication token for remote |
176-
177-### Description
178-
179-Removes an exact allowlist entry.
180-Use the same pattern string you added, including the `*.` prefix for wildcard entries.
181-
182-### Examples
183-
184-```bash
185-# Remove an exact domain
186-gordon autoroute allow remove example.com
187-
188-# Remove a wildcard domain
189-gordon autoroute allow remove "*.staging.example.com"
190-
191-# Remote (override)
192-gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
193-```
194-
195-### Output
196-
197-```text
198-✓ Allowed domain removed
199-```
200-
201-## Related
202-
203-- [CLI Overview](./index.md)
204-- [Routes Command](./routes.md)
205-- [Config Command](./config.md)
M docs/cli/backup.md +77 -27
@@ -1,71 +1,121 @@
11 # Backup Command
22
3-Manage database backups and volume backups.
3+Manage app database backups and app volume backups.
44
5-## gordon backups databases
5+Backups are identified by app, service, and the declared resource. Domains are
6+routing addresses and are never a backup identity.
7+
8+## Declaring backup targets
9+
10+A service declares its databases and volumes in the app manifest, and lists
11+which of them are backed up:
12+
13+```toml
14+[[service]]
15+name = "api"
16+image = "registry.example.com/shop/api:1.4.2"
17+
18+[[service.database]]
19+name = "orders"
20+type = "postgres"
21+schedule = "daily"
22+
23+[[service.volume]]
24+name = "data"
25+path = "/var/lib/data"
26+
27+[service.backup]
28+postgres = ["orders"]
29+volume = ["data"]
30+```
31+
32+A declared database or volume that the service's backup declaration does not
33+reference is not a backup target.
34+
35+## gordon backups
636
737 Database backups are logical PostgreSQL backups made with `pg_dump`.
838
939 ```bash
10-gordon backups databases <subcommand>
40+gordon backups <subcommand>
1141 ```
1242
1343 Subcommands:
1444
15-- `list [domain]` - List database backups
16-- `run <domain> [--db <name>]` - Trigger an immediate database backup
17-- `detect <domain>` - Detect supported databases for a domain
18-- `status` - Show database backup status
19-
20-Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
45+- `list [app]` - List stored backups, for every app or one app
46+- `run <app> --service <service> --database <database>` - Run one declared
47+ database backup now
48+- `status` - Show stored backups plus declared targets that have no completed
49+ backup yet
2150
22-## gordon backups volumes
51+## gordon backups volume
2352
24-Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
53+Volume backups are best-effort filesystem archives of the app's declared
54+volumes, uploaded to S3.
2555
2656 ```bash
27-gordon backups volumes <subcommand>
57+gordon backups volume <subcommand>
2858 ```
2959
3060 Subcommands:
3161
32-- `list [domain]` - List completed volume backup archives
33-- `run [domain] [--volume <name>]` - Trigger volume backups now
62+- `list [app]` - List completed volume backup archives
63+- `run <app> --service <service> --volume <volume>` - Run one declared volume
64+ backup now
3465 - `status` - Show completed archives plus current/recent in-memory job state
3566
36-Volume backups exclude bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes. Live archives are not application-consistent unless the application is quiesced or stopped.
67+Volume archives are not application-consistent unless the application is
68+quiesced or stopped.
69+
70+## Selectors
71+
72+`--service` and the resource flag (`--database`, `--volume`) select the target:
73+
74+- both given: the target must exist, otherwise the command reports not found;
75+- one omitted: it is only allowed when exactly one compatible target remains;
76+- several candidates: the command reports the ambiguity and lists the safe
77+ `service/resource` names.
78+
79+Nothing is ever chosen by guessing.
3780
3881 ## Examples
3982
4083 ```bash
4184 # Database backups
42-gordon backups databases list
43-gordon backups databases run app.example.com --db postgres
44-gordon backups databases detect app.example.com
45-gordon backups databases status
85+gordon backups list
86+gordon backups list shop
87+gordon backups run shop --service api --database orders
88+gordon backups status
4689
4790 # Volume backups
48-gordon backups volumes list
49-gordon backups volumes list app.example.com
50-gordon backups volumes run
51-gordon backups volumes run app.example.com --volume gordon-app-example-com-data
52-gordon backups volumes status
91+gordon backups volume list
92+gordon backups volume list shop
93+gordon backups volume run shop --service api --volume data
94+gordon backups volume status
5395 ```
5496
97+## Scheduling
98+
99+The installation backup schedule runs every declared database whose own
100+`schedule` matches the firing tier, and applies retention under the app name.
101+Volume declarations carry no schedule of their own: the installation volume
102+backup interval runs the declared volume targets.
103+
55104 ## JSON Output
56105
57-Database and volume list commands support `--json`.
106+Every list, run, and status command supports `--json`.
58107
59108 ```bash
60-gordon backups volumes list --json
109+gordon backups volume list --json
61110 ```
62111
63112 ## Required Permissions
64113
65-- Read operations (`list`, `status`, `detect`) require `admin:status:read`.
114+- Read operations (`list`, `status`) require `admin:status:read`.
66115 - `run` requires `admin:config:write`.
67116
68117 ## Related
69118
70119 - [CLI Commands](./index.md)
71120 - [Backups Configuration](../config/backups.md)
121+- [Apps Configuration](../config/apps.md)
D docs/cli/bootstrap.md +0 -80
@@ -1,80 +0,0 @@
1-# Bootstrap Command
2-
3-Create or update route configuration, attachments, and secrets in one command.
4-
5-## gordon bootstrap
6-
7-### Synopsis
8-
9-```bash
10-gordon bootstrap <domain> <image> [options]
11-```
12-
13-### Arguments
14-
15-| Argument | Description |
16-|----------|-------------|
17-| `<domain>` | The route domain to create or update |
18-| `<image>` | The image to assign to the route |
19-
20-### Options
21-
22-| Option | Description |
23-|--------|-------------|
24-| `--attachment` | Add an attachment to the route (repeatable) |
25-| `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) |
26-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
27-| `--token` | Authentication token for remote |
28-
29-### Description
30-
31-`gordon bootstrap` is the recommended first-step setup workflow.
32-
33-- Creates the route when it does not exist.
34-- Updates the route when it already exists.
35-- Can attach services and set environment variables as part of the same command.
36-- Does not push or deploy the image.
37-
38-Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image.
39-
40-### Examples
41-
42-```bash
43-# First-time route setup
44-gordon bootstrap app.example.com myapp:latest
45-
46-# Then push and deploy the image
47-gordon push myapp:latest --domain app.example.com --build --no-confirm
48-
49-# First-time route setup with a database attachment and environment variable
50-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
51-
52-# Multiple attachments and environment variables
53-gordon bootstrap app.example.com myapp:latest \
54- --attachment postgres:18 \
55- --attachment redis:7-alpine \
56- --env APP_ENV=production \
57- --env LOG_LEVEL=info
58-
59-# Remote target
60-gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
61-
62-# Push custom attachment image first
63-gordon attachments push pitlane-pgsql --build
64-
65-# Then push and deploy the route image
66-gordon push myapp --build --no-confirm
67-```
68-
69-### Notes
70-
71-- `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing.
72-- Run `gordon push` after bootstrap to upload and deploy the image.
73-- If attachments use custom images that are not available from a public registry, push those attachment images with `gordon attachments push` before deploying the route image.
74-
75-## Related
76-
77-- [CLI Overview](./index.md)
78-- [Attachments Commands](./attachments.md)
79-- [Routes Commands](./routes.md)
80-- [Push Command](./push.md)
M docs/cli/config.md +10 -16
@@ -17,8 +17,9 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1717
1818 ## gordon config show
1919
20-Display the Gordon server configuration including server settings,
21-auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default.
20+Display the Gordon installation configuration including server settings,
21+network isolation, volumes, and external route domains. App routes live
22+under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default.
2223
2324 ```bash
2425 gordon config show
@@ -41,17 +42,15 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
4142 "registry_port": 5000,
4243 "registry_domain": "reg.example.com"
4344 },
44- "auto_route": {
45- "enabled": true,
46- "allowed_domains": ["example.com", "*.staging.example.com"]
47- },
4845 "network_isolation": {
4946 "enabled": true,
50- "prefix": "gordon_"
47+ "prefix": "gordon"
48+ },
49+ "volumes": {
50+ "auto_create": true,
51+ "prefix": "gordon",
52+ "preserve": true
5153 },
52- "routes": [
53- {"domain": "app.example.com", "image": "myapp:latest"}
54- ],
5554 "external_routes": [
5655 {"domain": "reg.example.com"}
5756 ]
@@ -60,13 +59,8 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
6059
6160 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
6261
63-### Auto-Route Allowed Domains
64-
65-The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md).
66-
6762 ## Related
6863
6964 - [CLI Overview](./index.md)
70-- [Auto-Route Commands](./autoroute.md)
7165 - [Status Command](./status.md)
72-- [Routes Command](./routes.md)
66+- [Apps Commands](./apps.md)
M docs/cli/images.md +21 -3
@@ -59,11 +59,13 @@ gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry]
5959
6060 By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations.
6161
62+A prune only ever deletes resources it can positively prove safe. Every other candidate is reported as `protected` or `unknown` and left in place; the command still succeeds. See [Prune safety](#prune-safety).
63+
6264 Flags:
6365
6466 | Flag | Default | Description |
6567 |------|---------|-------------|
66-| `--dry-run` | `false` | Show prune behavior without applying changes |
68+| `--dry-run` | `false` | Run the full inventory and planning, then report; nothing is deleted |
6769 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
6870 | `--dangling` | `false` | Restrict scope to dangling runtime images only |
6971 | `--registry` | `false` | Restrict scope to registry tag retention only |
@@ -80,7 +82,22 @@ Flags:
8082
8183 - `latest` is always preserved when present.
8284 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.
83-- `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.
85+- A tag named by durable app state survives beyond the retention window.
86+- `--keep-releases=0` skips registry tag and blob cleanup entirely; dangling runtime prune still runs.
87+
88+## Prune safety
89+
90+Gordon prunes by ownership, not by name or age. Each candidate gets one verdict:
91+
92+| Verdict | Meaning |
93+|---------|---------|
94+| `eligible` | Every fact needed to prove the resource safe was read completely, and no durable record claims it. It is deleted. |
95+| `protected` | A durable fact claims it: a desired or active app service (including stopped apps), a recovery inhibition, a staged or committed apply intent, an unfinished operation journal entry, container use, a pending upload, an ownership record, or the OCI closure of any of those. It survives. |
96+| `unknown` | A fact needed to prove it safe was missing, unreadable, or unsupported. It survives. |
97+
98+Runtime images used by any container, running or stopped, always survive. Registry blobs reachable from a retained or protected manifest survive, including blobs shared with another tag. When a retained manifest cannot be read, the blobs whose safety depended on it become `unknown` rather than eligible.
99+
100+Both `--dry-run` and the executed prune return the same report: the verdict counts, the deleted identities, any deletion failures, every inventory gap, and the skipped candidates with their reason codes. A prune that deletes nothing is a success.
84101
85102 ## gordon images tags
86103
@@ -128,7 +145,8 @@ gordon images prune --dangling
128145 # Prune registry tags only, keeping latest + 5 previous
129146 gordon images prune --registry --keep-releases 5
130147
131-# Preview cleanup without applying
148+# Inspect what prune would remove without applying
149+# (also lists every skipped candidate with its reason)
132150 gordon images prune --dry-run
133151
134152 # Skip confirmation prompt
M docs/cli/index.md +43 -109
@@ -1,8 +1,8 @@
11 # CLI Commands
22
3-Gordon provides a command-line interface for server management, deployment, and authentication.
3+Gordon provides a command-line interface for server management, app deployment, and authentication.
44
5-Most `list` commands also support `--json` for machine-readable output.
5+Most `list`/`show` commands also support `--json` for machine-readable output.
66
77 Commands are organized by where they run:
88
@@ -20,30 +20,23 @@ Commands are organized by where they run:
2020
2121 ## Management Commands (local or remote)
2222
23-Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.
23+Management commands use the authenticated daemon API. By default they connect through the owner-only local Unix socket; add `--remote` to use authenticated HTTP/TLS against another Gordon instance.
2424
2525 | Command | Description | Documentation |
2626 |---------|-------------|---------------|
27-| `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |
28-| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |
29-| `gordon backups` | Manage database backups | [backup](./backup.md) |
30-| `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |
27+| `gordon apps` | Manage applications (apply, deploy, lifecycle) | [apps](./apps.md) |
28+| `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
3129 | `gordon config show` | Show server configuration | [config](./config.md) |
32-| `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
3330 | `gordon images` | List and prune images | [images](./images.md) |
34-| `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
31+| `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) |
3532 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
36-| `gordon preview` | Create or manage preview environments | [preview](../config/preview.md) |
37-| `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
38-| `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |
39-| `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
40-| `gordon restart` | Restart a running container | [restart](./restart.md) |
41-| `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) |
42-| `gordon routes` | Manage routes | [routes](./routes.md) |
43-| `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
33+| `gordon push` | Tag and push an image (never deploys) | [push](./push.md) |
34+| `gordon reload` | Reload installation configuration | [serve](./serve.md#gordon-reload) |
35+| `gordon secrets` | Manage installation secrets | [secrets](./secrets.md) |
4436 | `gordon status` | Show Gordon server status | [status](./status.md) |
37+| `gordon tls` | Inspect TLS status | [tls](./tls.md) |
4538 | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
46-| `gordon volumes` | Manage volumes | - |
39+| `gordon volumes` | Manage volumes | [volumes](./volumes.md) |
4740
4841 ## Client Commands
4942
@@ -60,36 +53,30 @@ Management commands run locally through in-process services by default. Add `--r
6053 gordon serve
6154 gordon serve --config /path/to/config.toml
6255
63-# Reload configuration
56+# Reload installation configuration (never activates app state)
6457 gordon reload
6558
66-# Deploy a specific route
67-gordon deploy myapp.example.com
68-
69-# Restart a running container
70-gordon restart myapp.example.com
71-
72-# First-time route setup
73-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
74-
75-# Then push and deploy
76-gordon push myapp:latest --domain app.example.com --build --no-confirm
77-
78-# Push an image and deploy
79-gordon push myapp --build
80-
81-# Push and deploy without confirmation
82-gordon push myapp --no-confirm
83-
84-# Pin to a specific tag
85-gordon pin myapp.example.com
59+# Applications (daemon-owned; fail without a reachable daemon)
60+gordon apps apply --file blog.toml
61+gordon apps apply --file blog.toml --deploy
62+gordon apps list
63+gordon apps show blog
64+gordon apps diff blog
65+gordon apps deploy blog
66+gordon apps restart blog
67+gordon apps stop blog
68+gordon apps start blog
69+gordon apps remove blog
70+
71+# Push an image (OCI transfer only; deploy separately)
72+gordon push myapp --build --remote prod
8673
8774 # View logs
88-gordon logs # Gordon process logs
89-gordon logs -f # Follow process logs
90-gordon logs -n 100 # Last 100 lines
91-gordon logs myapp.example.com # Container logs for myapp.example.com
92-gordon logs myapp.example.com -f # Follow container logs
75+gordon logs # Gordon process logs
76+gordon logs -f # Follow process logs
77+gordon logs -n 100 # Last 100 process-log lines
78+gordon apps logs blog --service web # App service logs
79+gordon apps logs blog --service web --follow # Follow app service logs
9380
9481 # Check version
9582 gordon version
@@ -100,14 +87,14 @@ gordon traffic status --remote prod --json
10087
10188 # Backups
10289 gordon backups list
103-gordon backups run app.example.com
104-gordon backups detect app.example.com
90+gordon backups run shop --service api --database orders
91+gordon backups volume run shop --service api --volume data
10592 gordon backups status
10693
10794 # Images
10895 gordon images list
109-gordon images prune --runtime-only
110-gordon images prune --keep 3
96+gordon images prune --dry-run
97+gordon images prune --keep-releases 3
11198
11299 # Authentication
113100 gordon auth login --remote https://gordon.example.com --token $TOKEN
@@ -119,21 +106,9 @@ gordon auth token list
119106 gordon auth token revoke <token-id>
120107 gordon auth internal
121108
122-# Routes
123-gordon routes list
124-gordon routes status
125-gordon routes add myapp.example.com myapp:latest
126-gordon routes remove myapp.example.com
127-gordon routes deploy myapp.example.com
128-
129-# Attachments
130-gordon attachments list
131-gordon attachments add app.example.com postgres:18
132-gordon attachments remove app.example.com postgres:18
133-
134109 # Secrets
135110 gordon secrets list myapp.example.com
136-gordon secrets set myapp.example.com DATABASE_URL "postgres://..."
111+gordon secrets set myapp.example.com --from-file ./app.env
137112 gordon secrets remove myapp.example.com DATABASE_URL
138113
139114 # Remotes
@@ -141,21 +116,9 @@ gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
141116 gordon remotes list
142117 gordon remotes use prod
143118
144-# Preview environments
145-gordon preview create app.example.com --branch feature-x
146-gordon preview list
147-gordon preview extend app.example.com --branch feature-x
148-gordon preview delete app.example.com --branch feature-x
149-
150119 # Volumes
151120 gordon volumes list
152121 gordon volumes prune
153-
154-# Auto-route allowlist
155-gordon autoroute allow list
156-gordon autoroute allow add example.com
157-gordon autoroute allow add "*.staging.example.com"
158-gordon autoroute allow remove example.com
159122 ```
160123
161124 ## Global Options
@@ -173,55 +136,26 @@ The CLI can target remote Gordon instances using client config, an active remote
173136 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
174137 when you want to bypass your saved configuration.
175138
176-When no explicit remote is selected and no active remote is configured, Gordon can now
177-**auto-infer a saved remote** for commands that already have a concrete target. It probes your
178-saved remotes and uses the remote automatically when exactly one matches. If multiple remotes
179-match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe
180-fails, Gordon also stops rather than guessing.
181-
182-Auto-inference currently applies to target-based commands such as:
183-- `gordon push`
184-- `gordon attachments push`
185-- `gordon deploy <domain>`
186-- `gordon restart <domain>`
187-- `gordon pin <domain>` / `gordon pin list <domain>`
188-- `gordon routes show <domain>` / `gordon routes remove <domain>`
189-- `gordon secrets list|set|remove <domain>`
190-- `gordon attachments list <target>` / `gordon attachments add|remove <target> ...`
191-- `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>`
192-- `gordon images tags <repository>`
193-- `gordon logs <domain>`
194-
195-`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
196-nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
197-one to force a single target.
139+When no explicit remote is selected and no active remote is configured, Gordon can
140+**auto-infer a saved remote** for `gordon push`. It probes your saved remotes and uses the
141+remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
142+an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
143+stops rather than guessing.
198144
199145 ```bash
200-# Aggregate routes views
201-gordon routes list
202-gordon routes status
203-
204-# Single-target override
205-gordon routes list --remote prod
206-GORDON_REMOTE=prod gordon routes status
207-
208146 # Auto-inferred single match from saved remotes
209147 gordon push myapp --build
210-gordon deploy app.example.com
211148 ```
212149
213150 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
214-If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
215151
216152 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
217153 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`
218154 or in a specific entry in `~/.config/gordon/remotes.toml`.
219-For Tailscale setups, you can also avoid `--insecure` by using the machine `*.ts.net` name with Tailscale-issued TLS certs in Gordon server config.
220-Your public app domains can still use wildcard DNS and normal reverse-proxy routing.
221155
222156 ```bash
223157 # Using flags (use the gordon_domain from remote Gordon config)
224-gordon routes list --remote https://gordon.example.com --token $TOKEN
158+gordon status --remote https://gordon.example.com --token $TOKEN
225159
226160 # Against self-signed/private CA endpoint
227161 gordon --remote https://gordon.example.com --token $TOKEN --insecure status
@@ -230,7 +164,7 @@ gordon --remote https://gordon.example.com --token $TOKEN --insecure status
230164 export GORDON_REMOTE=https://gordon.example.com
231165 export GORDON_TOKEN=$TOKEN
232166 export GORDON_INSECURE=true
233-gordon routes list
167+gordon status
234168 ```
235169
236170 ## Exit Codes
M docs/cli/networks.md +0 -2
@@ -47,5 +47,3 @@ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
4747 ## Related
4848
4949 - [CLI Overview](./index.md)
50-- [Routes Command](./routes.md)
51-- [Attachments Command](./attachments.md)
D docs/cli/pin.md +0 -72
@@ -1,72 +0,0 @@
1-# Pin Command
2-
3-Pin a route to a specific image tag.
4-
5-## gordon pin
6-
7-### Synopsis
8-
9-```bash
10-gordon pin <domain> [options]
11-gordon pin list <domain>
12-```
13-
14-### Arguments
15-
16-| Argument | Description |
17-|----------|-------------|
18-| `<domain>` | The route domain to pin |
19-
20-### Options
21-
22-| Option | Description |
23-|--------|-------------|
24-| `--tag` | Target tag (skips interactive selection) |
25-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
26-| `--token` | Authentication token for remote |
27-| `--json` | Output as JSON (for `pin list`) |
28-
29-### Description
30-
31-`gordon pin` deploys a selected image tag for the given route. Tags are
32-listed from the Gordon registry, with semver tags sorted in descending
33-order first, followed by non-semver tags.
34-
35-Use cases:
36-- **Rollback** — revert to a previous stable version
37-- **Forward** — advance to a newer version
38-- **Lock** — pin to a specific version instead of following `latest`
39-- **Test** — switch to an experimental tag
40-
41-`gordon pin list <domain>` lists available tags without deploying and
42-marks the current tag.
43-
44-If the selected tag matches the current running tag, no action is taken.
45-
46-### Examples
47-
48-```bash
49-# Interactive selection
50-gordon pin myapp.example.com
51-
52-# Pin to a specific tag
53-gordon pin myapp.example.com --tag v1.2.0
54-
55-# List available tags for a domain
56-gordon pin list myapp.example.com
57-
58-# List available tags as JSON
59-gordon pin list myapp.example.com --json
60-```
61-
62-### Notes
63-
64-- Local by default; remote mode optional. See [CLI Overview](./index.md) for targeting options.
65-- Tags are read from the Gordon registry configured on the server.
66-
67-## Related
68-
69-- [CLI Overview](./index.md)
70-- [Push Command](./push.md)
71-- [Routes Command](./routes.md)
72-- [Deployment Rollback Strategies](../deployment/rollback.md)
D docs/cli/preview.md +0 -61
@@ -1,61 +0,0 @@
1-# gordon preview
2-
3-Create and manage ephemeral preview environments. Each preview gets its own subdomain, container, and optionally cloned volumes from the base route.
4-
5-## Commands
6-
7-### `gordon preview create`
8-
9-Build, push, and deploy a preview environment.
10-
11-```bash
12-gordon preview create myapp
13-gordon preview create myapp --ttl 72h
14-gordon preview create myapp --no-build --no-data
15-```
16-
17-| Flag | Description |
18-|------|-------------|
19-| `--ttl` | Override TTL duration (e.g., `72h`) |
20-| `--no-build` | Skip image build (use existing image) |
21-| `--no-data` | Skip volume cloning from base route |
22-| `--platform` | Target platform for build (default `linux/amd64`) |
23-
24-### `gordon preview list`
25-
26-List active preview environments with status and remaining TTL.
27-
28-```bash
29-gordon preview list
30-gordon preview list --json
31-```
32-
33-| Flag | Description |
34-|------|-------------|
35-| `--json` | Output as JSON |
36-
37-### `gordon preview delete`
38-
39-Tear down a preview environment and clean up its resources.
40-
41-```bash
42-gordon preview delete myapp-feature-x
43-```
44-
45-### `gordon preview extend`
46-
47-Extend the TTL of an active preview.
48-
49-```bash
50-gordon preview extend myapp-feature-x
51-gordon preview extend myapp-feature-x --ttl 48h
52-```
53-
54-| Flag | Description |
55-|------|-------------|
56-| `--ttl` | Additional TTL to add (default `24h`) |
57-
58-## Related
59-
60-- [Preview Configuration](../config/preview.md)
61-- [Deploy](./serve.md#gordon-deploy)
M docs/cli/push.md +19 -51
@@ -1,6 +1,8 @@
11 # Push Command
22
3-Tag, push, and optionally deploy an image to the Gordon registry.
3+Tag and push an image to the Gordon registry. Push transfers OCI content
4+only: it never deploys. Deploy separately with `gordon apps deploy` after
5+applying the manifest that references the pushed tag.
46
57 ## gordon push
68
@@ -25,35 +27,22 @@ gordon push [image] [options]
2527 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
2628 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
2729 | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
28-| `--no-confirm` | Skip deploy confirmation prompt |
29-| `--no-deploy` | Push only; skip deployment prompt |
30-| `--domain` | Explicit deploy target override |
3130 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
3231 | `--token` | Authentication token for remote |
3332
3433 ### Description
3534
36-`gordon push` tags the selected image for the Gordon registry, pushes it, and
37-optionally deploys matching routes.
35+`gordon push` tags the selected image for the Gordon registry and pushes it.
3836
3937 If you do not pass `--remote` and no active remote is configured, Gordon tries to
40-infer the correct saved remote by probing your saved remotes for a matching route.
38+infer the correct saved remote by probing your saved remotes for the image.
4139 It auto-selects only when exactly one remote matches. If multiple remotes match,
4240 or a saved remote cannot be probed safely, the command stops and asks you to use
4341 `--remote` explicitly.
4442
45-Image resolution order:
46-1. `--domain` is the explicit deploy target override for legacy workflows
47-2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup
48-3. Dotted positional refs probe image routes first, then fall back to legacy domain lookup
49-4. No-arg mode auto-detects from the Dockerfile label or current directory
43+Domain-style push targets are retired: push an image name, then
44+`gordon apps apply` the manifest that references the pushed tag.
5045
51-The pushed version still comes from `--tag`, CI tag refs, or `git describe --tags --dirty`.
52-
53-To push attachment images (databases, caches, etc.), use `gordon attachments push`.
54-
55-- For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.
56-- The registry and repository are derived from the route image on the server.
5746 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
5847 then falls back to `git describe --tags --dirty` (for example
5948 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
@@ -69,20 +58,11 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus
6958
7059 When used with `--remote`, gordon push authenticates in two ways:
7160
72-- **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token
61+- **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token
7362 - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
7463
7564 This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
7665
77-### Deploy Modes
78-
79-When the token has `admin:config:write` scope, the CLI manages deployment explicitly
80-(DeployIntent → push → Deploy). This gives the CLI control over deploy timing.
81-
82-When the token lacks `admin:config:write`, the CLI pushes the image and the server
83-auto-deploys when it receives it via its event listener. The CLI logs:
84-`info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive`
85-
8666 ### Version Auto-Detection
8767
8868 Gordon reads version tags from CI environment variables (in priority order):
@@ -99,40 +79,32 @@ Gordon reads version tags from CI environment variables (in priority order):
9979 ### Examples
10080
10181 ```bash
102-# Build, push, and deploy (auto-detect image name)
103-gordon push --build --remote https://gordon.example.com --no-confirm
104-
105-# Push an image and deploy it
106-gordon push myapp --remote https://gordon.example.com --no-confirm
82+# Build, push (auto-detect image name)
83+gordon push --build --remote https://gordon.example.com
10784
108-# Push and deploy to an explicit domain
109-gordon push myapp:latest --domain app.example.com --remote https://gordon.example.com --no-confirm
85+# Push an image
86+gordon push myapp --remote https://gordon.example.com
11087
111-# Tagged refs still resolve routes by image name
112-gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy
88+# Push a fully qualified ref
89+gordon push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com
11390
114-# Push existing local image, skip deploy
115-gordon push myapp --tag v1.2.0 --no-deploy
91+# Push existing local image with an explicit tag
92+gordon push myapp --tag v1.2.0 --remote https://gordon.example.com
11693
11794 # Build for ARM and pass build args
118-gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
95+gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com
11996
12097 # Build from a custom Dockerfile path
121-gordon push myapp --build -f docker/app/Dockerfile
122-
123-# Legacy compatibility: domain-looking positional target
124-gordon push app.example.com --no-confirm
98+gordon push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com
12599
126100 # CI/CD usage (single env var, no docker login needed)
127101 export GORDON_TOKEN="your-token"
128-gordon push myapp --build --remote https://gordon.example.com --no-confirm
102+gordon push myapp --build --remote https://gordon.example.com
129103 ```
130104
131105 ### Notes
132106
133107 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
134-- `gordon push` requires the target route to already exist so it can resolve the deploy target.
135- Use `gordon bootstrap` for first deploys.
136108 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
137109 install the `docker-buildx-plugin` package.
138110 - Gordon uses native registry uploads instead of shelling out to `docker push`.
@@ -144,8 +116,4 @@ gordon push myapp --build --remote https://gordon.example.com --no-confirm
144116 ## Related
145117
146118 - [CLI Overview](./index.md)
147-- [Deployment Overview](../deployment/index.md)
148-- [GitHub Actions](../deployment/github-actions.md)
149-- [Attachments Commands](./attachments.md)
150-- [Routes Command](./routes.md)
151119 - [Authentication](../config/auth.md)
M docs/cli/remotes.md +11 -22
@@ -163,15 +163,13 @@ gordon remotes use <name>
163163 ### Description
164164
165165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166-`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
167166
168-If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for commands with a concrete target (for example `gordon push myapp`, `gordon deploy app.example.com`, or `gordon images tags myapp`). It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.
167+If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.
169168
170169 ```bash
171170 gordon remotes use prod
172171 gordon secrets list app.com # Uses prod remote automatically
173-gordon routes list # Aggregates local + saved remotes
174-gordon routes status # Aggregates local + saved remotes
172+gordon status # Uses prod remote automatically
175173 ```
176174
177175 ### Example
@@ -246,7 +244,7 @@ token = "eyJ..."
246244
247245 When multiple sources specify remote or token, the CLI uses this priority:
248246
249-For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
247+For aggregate views, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
250248 Auto-inference is not used for those aggregate views.
251249
252250 **Remote target selection:**
@@ -271,13 +269,8 @@ Auto-inference is not used for those aggregate views.
271269 This allows overriding specific values while keeping defaults:
272270
273271 ```bash
274-# Aggregate routes views
275-gordon routes list
276-gordon routes status
277-
278272 # Force one target
279-GORDON_REMOTE=prod gordon routes list
280-GORDON_REMOTE=prod gordon routes status
273+GORDON_REMOTE=prod gordon status
281274 ```
282275
283276 ---
@@ -295,11 +288,11 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
295288 # Work with prod
296289 gordon remotes use prod
297290 gordon secrets list myapp.example.com
298-gordon routes list --remote prod
291+gordon status --remote prod
299292
300293 # Switch to staging
301294 gordon remotes use staging
302-GORDON_REMOTE=staging gordon routes status
295+GORDON_REMOTE=staging gordon status
303296 ```
304297
305298 ### CI/CD Pipeline
@@ -311,21 +304,17 @@ env:
311304 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
312305
313306 steps:
314- - name: Deploy to Gordon
307+ - name: Push to Gordon
315308 run: |
316- gordon routes deploy myapp.example.com
309+ gordon push myapp --build --remote ${{ secrets.GORDON_URL }}
317310 ```
318311
319312 ### Compare Environments
320313
321314 ```bash
322-# Aggregate route inventory/status across local + saved remotes
323-gordon routes list
324-gordon routes status
325-
326315 # Compare one target at a time
327-gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
328-GORDON_REMOTE=staging gordon routes status
316+gordon status --remote https://gordon.example.com --token $PROD_TOKEN
317+GORDON_REMOTE=staging gordon status
329318
330319 # Or switch between active remotes for other commands
331320 gordon remotes use prod && gordon secrets list myapp.example.com
@@ -354,7 +343,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal
354343
355344 If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA.
356345
357-`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`.
346+`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: the reverse proxy can still serve wildcard app domains like `*.example.com`.
358347
359348 ## Migration from [client] Config
360349
D docs/cli/restart.md +0 -54
@@ -1,54 +0,0 @@
1-# Restart Command
2-
3-Restart a running container for a route.
4-
5-## gordon restart
6-
7-### Synopsis
8-
9-```bash
10-gordon restart <domain> [options]
11-```
12-
13-### Arguments
14-
15-| Argument | Description |
16-|----------|-------------|
17-| `<domain>` | The route domain to restart |
18-
19-### Options
20-
21-| Option | Description |
22-|--------|-------------|
23-| `--with-attachments` | Also restart attached services (databases, caches) |
24-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
25-| `--token` | Authentication token for remote |
26-
27-### Description
28-
29-Restarts the container for the specified route. This is useful after updating
30-environment variables or secrets without performing a full redeploy.
31-
32-When `--with-attachments` is set, Gordon also restarts any attachment containers
33-for the route.
34-
35-### Examples
36-
37-```bash
38-# Restart main container only
39-gordon restart myapp.example.com
40-
41-# Restart with attachments
42-gordon restart myapp.example.com --with-attachments
43-```
44-
45-### Notes
46-
47-- Local mode works when run on the Gordon host. It uses the same deploy-signal path as `gordon deploy <domain>`.
48-- `--with-attachments` is only supported in remote mode.
49-- In remote mode, target your Gordon admin endpoint (for example `https://gordon.example.com`), not the registry host (for example `https://reg.example.com`).
50-
51-## Related
52-
53-- [CLI Overview](./index.md)
54-- [Secrets Command](./secrets.md)
D docs/cli/routes.md +0 -442
@@ -1,442 +0,0 @@
1-# Routes Commands
2-
3-Manage routes on local or remote Gordon instances.
4-
5-Most remote-capable commands use client config or an active remote by default.
6-`gordon routes list` and `gordon routes status` are different: when neither
7-`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8-saved remote under its own heading. See [CLI Overview](./index.md).
9-
10-## gordon routes
11-
12-### Subcommands
13-
14-| Subcommand | Description |
15-|------------|-------------|
16-| `list` | List routes by domain and image |
17-| `status` | Show detailed route status |
18-| `show` | Show details for a single route |
19-| `add` | Create or update a route |
20-| `remove` | Remove a route |
21-| `deploy` | Deploy a specific route |
22-
23----
24-
25-## gordon routes list
26-
27-List routes by domain and image only.
28-
29-When no target is selected, Gordon shows local routes first, then each saved
30-remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31-target only.
32-
33-```bash
34-gordon routes list
35-gordon routes list --json
36-gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37-GORDON_REMOTE=prod gordon routes list
38-```
39-
40-### Output
41-
42-```text
43-Routes
44-
45-Local
46- app.example.com myapp:latest
47- api.example.com myapi:v2.1.0
48-
49-Remote: hetzner-vps
50- gordon.example.com gordon-webapp:latest
51-
52-Remote: igor
53- grafana.supri.xyz grafana
54- test.supri.xyz hello-test
55-```
56-
57-### JSON Output
58-
59-```bash
60-gordon routes list --json
61-```
62-
63-```json
64-[
65- {
66- "kind": "local",
67- "name": "local",
68- "routes": [
69- {
70- "domain": "app.example.com",
71- "image": "myapp:latest"
72- }
73- ]
74- },
75- {
76- "kind": "remote",
77- "name": "igor",
78- "url": "https://gordon.supri.xyz",
79- "routes": [
80- {
81- "domain": "grafana.supri.xyz",
82- "image": "grafana"
83- }
84- ]
85- }
86-]
87-```
88-
89-Single-target mode still returns a one-element array. Sections can also include
90-an `error` field when a target is unavailable.
91-
92-### Options
93-
94-| Option | Description |
95-|--------|-------------|
96-| `--json` | Output routes as JSON |
97-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98-| `--token` | Authentication token for remote |
99-
100----
101-
102-## gordon routes status
103-
104-Show detailed route status for each target.
105-
106-`routes status` uses the same target selection rules as `routes list`. When no
107-target is selected, Gordon shows local status first, then each saved remote
108-under its own heading.
109-
110-```bash
111-gordon routes status
112-gordon routes status --json
113-gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114-GORDON_REMOTE=prod gordon routes status
115-```
116-
117-### Output
118-
119-```text
120-Route Status
121-
122-Local
123- <rich tree for local routes>
124-
125-Remote: hetzner-vps
126- <rich tree for that remote>
127-
128-Remote: igor
129- <rich tree for that remote>
130-```
131-
132-The rich view keeps network grouping, container status, HTTP probe status, and
133-attachments within each target.
134-
135-### JSON Output
136-
137-```json
138-[
139- {
140- "kind": "local",
141- "name": "local",
142- "routes": [
143- {
144- "domain": "app.example.com",
145- "image": "myapp:latest",
146- "container_id": "abcd1234",
147- "container_status": "running",
148- "http_status": 200,
149- "network": "gordon-shared",
150- "attachments": [
151- {
152- "name": "postgres",
153- "image": "postgres:18",
154- "status": "running"
155- }
156- ]
157- }
158- ]
159- }
160-]
161-```
162-
163-Single-target mode still returns a one-element array. Sections can also include
164-an `error` field when a target is unavailable.
165-
166-### Options
167-
168-| Option | Description |
169-|--------|-------------|
170-| `--json` | Output routes as JSON |
171-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
172-| `--token` | Authentication token for remote |
173-
174----
175-
176-## gordon routes add
177-
178-Create a new route or update an existing route.
179-
180-```bash
181-gordon routes add <domain> <image>
182-gordon routes add myapp.example.com myapp:latest
183-```
184-
185-If the route already exists, Gordon updates it to the new image instead of failing.
186-The image does not need to be pushed to the Gordon registry before you add the route.
187-
188-### Arguments
189-
190-| Argument | Description |
191-|----------|-------------|
192-| `<domain>` | The domain name for the route |
193-| `<image>` | The container image to deploy |
194-
195-### Options
196-
197-| Option | Description |
198-|--------|-------------|
199-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
200-| `--token` | Authentication token for remote |
201-
202-### Examples
203-
204-```bash
205-# Local
206-gordon routes add myapp.example.com myapp:latest
207-gordon routes add api.example.com myapi:v2.1.0
208-
209-# Update an existing route
210-gordon routes add myapp.example.com myapp:v2
211-
212-# Remote (override)
213-gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
214-```
215-
216-### Notes
217-
218-- `gordon routes add` is idempotent: it creates the route when missing and updates it when present.
219-- You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image.
220-
221----
222-
223-## gordon routes show
224-
225-Show detailed information about a single route.
226-
227-```bash
228-gordon routes show <domain>
229-gordon routes show myapp.example.com
230-gordon routes show myapp.example.com --json
231-```
232-
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `<domain>` | The domain name of the route to inspect |
238-
239-### Options
240-
241-| Option | Description |
242-|--------|-------------|
243-| `--json` | Output route details as JSON |
244-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
245-| `--token` | Authentication token for remote |
246-
247-### Description
248-
249-Displays the configured image for the route plus any available container and HTTP health information.
250-In local-only mode, health data may be unavailable.
251-
252-### Examples
253-
254-```bash
255-# Local
256-gordon routes show myapp.example.com
257-
258-# JSON
259-gordon routes show myapp.example.com --json
260-
261-# Remote (override)
262-gordon routes show myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
263-```
264-
265-### JSON Output
266-
267-```json
268-{
269- "domain": "myapp.example.com",
270- "image": "myapp:latest",
271- "container_status": "running",
272- "http_status": 200
273-}
274-```
275-
276----
277-
278-## gordon routes diagnose
279-
280-Diagnose route configuration, runtime state, preserved volumes, and orphaned attachment containers.
281-
282-```bash
283-gordon routes diagnose <domain>
284-gordon routes diagnose myapp.example.com --json
285-```
286-
287-Use this after route deletion or failed deployment to see whether runtime state still exists and which safe cleanup commands to run next.
288-
289-### Output
290-
291-```text
292-Route diagnosis: myapp.example.com
293-Image: myapp:latest
294-Container: running abc123def456
295-Preserved volume: gordon-myapp-example-com-data
296-Orphaned attachment: postgres
297-Hint: persistent volumes are preserved by default
298-Hint: run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes
299-```
300-
301-### JSON Output
302-
303-```json
304-{
305- "domain": "myapp.example.com",
306- "configured": true,
307- "route": {"domain": "myapp.example.com", "image": "myapp:latest", "https": true},
308- "runtime": {"domain": "myapp.example.com", "container_id": "abc123def456", "container_status": "running"},
309- "volumes": [{"name": "gordon-myapp-example-com-data", "in_use": false}],
310- "orphaned_attachments": [{"container_id": "pg123", "name": "postgres", "image": "postgres:16", "status": "running", "owner": "myapp.example.com"}],
311- "hints": ["persistent volumes are preserved by default"]
312-}
313-```
314-
315-### Next Steps
316-
317-```bash
318-gordon routes purge myapp.example.com # dry-run retained resources
319-gordon routes purge myapp.example.com --attachments --force
320-```
321-
322----
323-
324-## gordon routes purge
325-
326-Review retained resources for a route and execute supported explicit cleanup actions only when forced.
327-
328-```bash
329-gordon routes purge myapp.example.com # dry-run
330-gordon routes purge myapp.example.com --attachments --force
331-gordon routes purge myapp.example.com --volumes # report preserved volumes for manual review
332-```
333-
334-### Dry-run Output
335-
336-```text
337-Purge dry-run: myapp.example.com
338-Preserved attachments:
339- postgres (running)
340-Purge candidate: route_container abc123def456
341-Preserved volume: gordon-myapp-example-com-data
342-Hint: dry-run only; add --force with explicit category flags to execute supported purge actions
343-```
344-
345-### Forced Attachment Cleanup Output
346-
347-```text
348-Purge completed: myapp.example.com
349-Removed containers:
350- postgres
351-Hint: attachment volumes and data were preserved
352-```
353-
354-Purge is intentionally conservative. Volumes are reported and preserved unless a dedicated volume deletion flow supports safe targeted deletion.
355-
356----
357-
358-## gordon routes remove
359-
360-Remove a route.
361-
362-Route removal is safe by default. Gordon removes the route from configuration and reconciles the active route container so it is no longer served or restarted by Gordon's monitor. Stateful data is preserved. Volumes and attachment data are not deleted unless a separate explicit purge flow requests destructive cleanup.
363-
364-```bash
365-gordon routes remove <domain>
366-gordon routes remove myapp.example.com
367-```
368-
369-### Arguments
370-
371-| Argument | Description |
372-|----------|-------------|
373-| `<domain>` | The domain name of the route to remove |
374-
375-### Options
376-
377-| Option | Description |
378-|--------|-------------|
379-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
380-| `--token` | Authentication token for remote |
381-
382-### Examples
383-
384-```bash
385-# Local
386-gordon routes remove myapp.example.com
387-
388-# Remote (override)
389-gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
390-
391-# Typical output
392-Route removed: myapp.example.com
393-
394-Removed containers:
395- gordon-myapp.example.com (abc123def456)
396-
397-Preserved attachments:
398- postgres (running)
399-```
400-
401----
402-
403-## gordon routes deploy
404-
405-Deploy or redeploy a specific route.
406-
407-```bash
408-gordon routes deploy <domain>
409-gordon routes deploy myapp.example.com
410-```
411-
412-### Arguments
413-
414-| Argument | Description |
415-|----------|-------------|
416-| `<domain>` | The domain name of the route to deploy |
417-
418-### Options
419-
420-| Option | Description |
421-|--------|-------------|
422-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
423-| `--token` | Authentication token for remote |
424-
425-### Description
426-
427-Triggers a fresh image pull and container redeployment for the specified route.
428-
429-### Examples
430-
431-```bash
432-# Local
433-gordon routes deploy myapp.example.com
434-
435-# Remote (override)
436-gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
437-```
438-
439-## Related
440-
441-- [CLI Overview](./index.md)
442-- [Routes Configuration](../config/routes.md)
M docs/cli/secrets.md +32 -84
@@ -1,32 +1,31 @@
11 # Secrets Commands
22
3-Manage secrets on local or remote Gordon instances.
4-
5-Remote targeting uses client config or an active remote by default.
6-When you provide a concrete domain and no remote is selected, Gordon can also
7-auto-infer a saved remote when exactly one match is found.
8-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
3+Manage installation secrets on local or remote Gordon instances.
94
105 Storage depends on the secrets backend:
116 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
127 - `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
138 - `unsafe`: secrets are stored in plain-text domain `.env` files
149
10+App secret VALUES stay in pass under
11+`gordon/apps/<uuid>/<service>/<name>` and are managed with
12+`gordon apps secrets`. The commands below manage installation secrets only.
13+
1514 ## gordon secrets
1615
1716 ### Subcommands
1817
1918 | Subcommand | Description |
2019 |------------|-------------|
21-| `list` | List all secrets for a domain |
22-| `set` | Set a secret value |
20+| `list` | List all secret keys for a domain |
21+| `set` | Set secrets for a domain from a file |
2322 | `remove` | Remove a secret |
2423
2524 ---
2625
2726 ## gordon secrets list
2827
29-List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets.
28+List all secret key names for a specific domain. Only key names are shown, never values.
3029
3130 ```bash
3231 gordon secrets list <domain>
@@ -62,13 +61,8 @@ gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --tok
6261 Secrets for app.mydomain.com
6362
6463 Key Value
65-DATABASE_URL ****
66-API_KEY ****
67-├─ [postgres]
68-│ ├─ POSTGRES_USER ****
69-│ └─ POSTGRES_PASSWORD ****
70-└─ [redis]
71- └─ REDIS_PASSWORD ****
64+DATABASE_URL (hidden)
65+API_KEY (hidden)
7266 ```
7367
7468 ### JSON Output
@@ -80,19 +74,7 @@ gordon secrets list app.mydomain.com --json
8074 ```json
8175 {
8276 "domain": "app.mydomain.com",
83- "secrets": {
84- "DATABASE_URL": "****",
85- "API_KEY": "****"
86- },
87- "attachments": {
88- "postgres": {
89- "POSTGRES_USER": "****",
90- "POSTGRES_PASSWORD": "****"
91- },
92- "redis": {
93- "REDIS_PASSWORD": "****"
94- }
95- }
77+ "keys": ["API_KEY", "DATABASE_URL"]
9678 }
9779 ```
9880
@@ -100,25 +82,24 @@ gordon secrets list app.mydomain.com --json
10082
10183 ## gordon secrets set
10284
103-Set a secret value for a domain.
85+Set secrets for a domain from a mode 0600 file containing one `KEY=value` pair per line.
10486
10587 ```bash
106-gordon secrets set <domain> <KEY=value>...
107-gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
88+gordon secrets set <domain> --from-file <path>
10889 ```
10990
11091 ### Arguments
11192
11293 | Argument | Description |
11394 |----------|-------------|
114-| `<domain>` | The domain name to set the secret for |
115-| `<KEY=value>...` | One or more secret key/value pairs |
95+| `<domain>` | The domain name to set secrets for |
11696
11797 ### Options
11898
11999 | Option | Description |
120100 |--------|-------------|
121-| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
101+| `--from-file` | Read KEY=value lines from a mode 0600 file (required) |
102+| `--json` | Output as JSON |
122103 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
123104 | `--token` | Authentication token for remote |
124105
@@ -126,18 +107,10 @@ gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
126107
127108 ```bash
128109 # Local
129-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db"
130-gordon secrets set myapp.example.com API_KEY="your-api-key"
110+gordon secrets set myapp.example.com --from-file ./app.env
131111
132112 # Remote (override)
133-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN
134-
135-# Set attachment secrets
136-gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
137-gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret
138-
139-# Multiple secrets at once
140-gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
113+gordon secrets set myapp.example.com --from-file ./app.env --remote https://gordon.mydomain.com --token $TOKEN
141114 ```
142115
143116 ---
@@ -161,8 +134,7 @@ gordon secrets remove <domain> <key>
161134
162135 | Option | Description |
163136 |--------|-------------|
164-| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
165-| `--force` | Remove without confirmation |
137+| `--force`, `-f` | Remove without confirmation |
166138 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
167139 | `--token` | Authentication token for remote |
168140
@@ -174,9 +146,6 @@ gordon secrets remove myapp.example.com DATABASE_URL
174146
175147 # Remote (override)
176148 gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
177-
178-# Remove attachment secret
179-gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
180149 ```
181150
182151 ---
@@ -186,15 +155,14 @@ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
186155 ### Setting Up Application Secrets
187156
188157 ```bash
189-# Database connection
190-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/mydb"
191-
192-# API keys
193-gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..."
194-gordon secrets set myapp.example.com SENDGRID_KEY="SG..."
158+# Write secrets to a protected file
159+cat > app.env <<'EOF'
160+DATABASE_URL=postgres://user:pass@postgres:5432/mydb
161+API_KEY=your-api-key
162+EOF
163+chmod 600 app.env
195164
196-# JWT secret
197-gordon secrets set myapp.example.com JWT_SECRET="your-jwt-secret-here"
165+gordon secrets set myapp.example.com --from-file ./app.env
198166
199167 # Verify
200168 gordon secrets list myapp.example.com
@@ -207,12 +175,8 @@ gordon secrets list myapp.example.com
207175 export GORDON_REMOTE=https://gordon.mydomain.com
208176 export GORDON_TOKEN=$GORDON_TOKEN
209177
210-# Update secrets before deploy
211-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL"
212-gordon secrets set myapp.example.com API_KEY="$API_KEY"
213-
214-# Deploy
215-gordon deploy myapp.example.com
178+# Update secrets
179+gordon secrets set myapp.example.com --from-file ./app.env
216180 ```
217181
218182 ### Rotating Secrets
@@ -220,28 +184,11 @@ gordon deploy myapp.example.com
220184 ```bash
221185 # Generate new secret
222186 NEW_JWT_SECRET=$(openssl rand -base64 32)
187+printf 'JWT_SECRET=%s\n' "$NEW_JWT_SECRET" > rotate.env
188+chmod 600 rotate.env
223189
224190 # Update the secret
225-gordon secrets set myapp.example.com JWT_SECRET="$NEW_JWT_SECRET"
226-
227-# Redeploy to pick up new secret
228-gordon deploy myapp.example.com
229-```
230-
231-### Attachment Secrets
232-
233-```bash
234-# Configure database credentials
235-gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
236-
237-# Configure cache credentials
238-gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret
239-
240-# Verify
241-gordon secrets list app.mydomain.com
242-
243-# Redeploy to pick up new secrets
244-gordon deploy app.mydomain.com
191+gordon secrets set myapp.example.com --from-file ./rotate.env
245192 ```
246193
247194 ## Related
@@ -249,3 +196,4 @@ gordon deploy app.mydomain.com
249196 - [CLI Overview](./index.md)
250197 - [Secrets Configuration](../config/secrets.md)
251198 - [Environment Variables](../config/env.md)
199+- [Apps Commands](./apps.md)
M docs/cli/serve.md +26 -97
@@ -23,8 +23,8 @@ gordon serve [options]
2323 Starts the Gordon server, which includes:
2424
2525 - **Container Registry** - Receives image pushes on the registry port
26-- **HTTP Proxy** - Routes traffic to containers on the proxy port
27-- **Event Bus** - Coordinates deployments and updates
26+- **Reverse Proxy** - Routes app hosts to recorded loopback backends
27+- **Event Bus** - Coordinates runtime notifications
2828 - **Config Watcher** - Monitors configuration file for changes
2929
3030 ### Configuration File Detection
@@ -68,8 +68,8 @@ Gordon responds to these signals:
6868 |--------|--------|
6969 | `SIGTERM` | Graceful shutdown |
7070 | `SIGINT` | Graceful shutdown (Ctrl+C) |
71-| `SIGUSR1` | Reload configuration |
72-| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |
71+| `SIGUSR1` | Reload installation configuration |
72+| `SIGUSR2` | Reserved; no app deployment action |
7373
7474 ### Running with systemd
7575
@@ -106,21 +106,16 @@ sudo loginctl enable-linger $USER
106106 7. Register event handlers
107107 8. Start config file watcher
108108 9. Start HTTP servers
109-10. Run best-effort startup recovery (sync existing containers, then recover configured routes)
109+10. Run best-effort startup recovery (reconcile apps intended to run from active state)
110110
111111 ### Startup Recovery
112112
113113 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.
114114
115-1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
116-2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.
117-3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
115+1. **Reconcile app boot state** - Gordon ensures apps intended to run are running from active state (stopped-intent apps stay stopped, pending desired revisions are never activated).
116+4. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
118117
119-This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.
120-
121-Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes.
122-
123-Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
118+Reload and startup recovery never activate pending desired app state, re-resolve images, or flip intent. They touch installation settings and runtime reconciliation only.
124119
125120 ### Shutdown Sequence
126121
@@ -135,7 +130,7 @@ Startup recovery is intentionally narrower than a manual deploy. It only starts
135130
136131 ## gordon reload
137132
138-Reload configuration and sync containers to match.
133+Reload installation configuration.
139134
140135 ### Synopsis
141136
@@ -147,10 +142,10 @@ gordon reload
147142
148143 Sends `SIGUSR1` to the running Gordon process, triggering:
149144
150-- Configuration file reload
151-- Route synchronization
152-- Deployment of containers for routes missing containers
153-- Attachment deployment
145+- Installation settings reload (live keys apply, restart-required keys are reported)
146+- Traffic/proxy state refresh from ACTIVE app projection
147+
148+Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation.
154149
155150 ### Example
156151
@@ -162,80 +157,18 @@ gordon reload
162157
163158 ---
164159
165-## gordon deploy
166-
167-Manually deploy or redeploy a specific route.
168-
169-### Synopsis
170-
171-```bash
172-gordon deploy <domain> [options]
173-```
174-
175-### Arguments
176-
177-| Argument | Description |
178-|----------|-------------|
179-| `<domain>` | The domain name of the route to deploy (required) |
180-
181-### Options
182-
183-| Option | Description |
184-|--------|-------------|
185-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
186-| `--token` | Authentication token for remote |
187-
188-Remote targeting uses client config or an active remote by default.
189-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
190-
191-### Description
192-
193-**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route.
194-
195-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request.
196-
197-Both local and remote manual deploys use Gordon's explicit deploy path:
198-
199-- Fresh image content is pulled for the route
200-- The specified route is redeployed
201-- Configured readiness checks and drain behavior apply when needed
202-
203-### Examples
204-
205-```bash
206-# Local deployment
207-gordon deploy myapp.example.com
208-gordon deploy api.example.com
209-
210-# Remote deployment (override)
211-gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
212-```
213-
214-### Use Cases
215-
216-- Recover from a failed deployment
217-- Force redeploy without pushing a new image
218-- Manual deployment when automatic deploy didn't trigger
219-- Trigger deployments on remote Gordon instances from CI/CD
220-
221----
222-
223160 ## gordon logs
224161
225-Display Gordon process logs or container logs.
162+Display Gordon daemon process logs. Application workload output is read with
163+`gordon apps logs APP --service SVC`, which resolves the app's active container
164+through the daemon.
226165
227166 ### Synopsis
228167
229168 ```bash
230-gordon logs [domain] [options]
169+gordon logs [options]
231170 ```
232171
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
238-
239172 ### Options
240173
241174 | Option | Short | Default | Description |
@@ -246,10 +179,8 @@ gordon logs [domain] [options]
246179 | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
247180 | `--token` | | | Authentication token for remote |
248181
249-Remote targeting uses client config or an active remote by default.
250-When you provide a concrete domain and no remote is selected, Gordon can also
251-auto-infer a saved remote when exactly one match is found.
252-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
182+Remote targeting uses client config or an active remote by default. Use
183+`--remote` and `--token` to override. See [CLI Overview](./index.md).
253184
254185 Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
255186
@@ -262,14 +193,12 @@ gordon logs -f # Follow logs
262193 gordon logs -n 100 # Last 100 lines
263194 gordon logs -f -n 200 # Follow, starting from last 200 lines
264195
265-# Container logs
266-gordon logs myapp.example.com # Last 50 lines from container
267-gordon logs myapp.example.com -f # Follow container logs
268-gordon logs myapp.example.com -n 100 # Last 100 lines from container
196+# App service logs
197+gordon apps logs blog --service web
198+gordon apps logs blog --service web --follow --remote prod
269199
270-# Remote mode (override)
200+# Remote process logs (override)
271201 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
272-gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
273202 ```
274203
275204 ### Log Locations
@@ -284,9 +213,9 @@ tail -f ~/.gordon/logs/gordon.log
284213 # With systemd
285214 journalctl --user -u gordon -f
286215
287-# Container logs via docker (local alternative)
288-docker logs --tail 50 myapp.example.com
289-docker logs -f myapp.example.com
216+# App service logs through Gordon
217+gordon apps logs blog --service web --tail 50
218+gordon apps logs blog --service web --follow
290219 ```
291220
292221 ---
M docs/cli/status.md +20 -23
@@ -1,10 +1,10 @@
11 # Status Command
22
3-Show Gordon server status and container health.
3+Show Gordon server status and the app fleet summary.
44
55 ## gordon status
66
7-Display server configuration and container status for all routes.
7+Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`.
88
99 ```bash
1010 gordon status
@@ -24,14 +24,13 @@ Gordon Status
2424 Gordon Domain: gordon.example.com
2525 Registry Port: 5000
2626 Server Port: 8088
27-Routes: 3
28-Auto-Route: true
27+Apps: 3
2928 Network Isolation: false
3029
3130 Container Status:
32- app.example.com: running
33- api.example.com: running
34- worker.example.com: stopped
31+ blog: active
32+ shop: deploying
33+ old-site: stopped
3534 ```
3635
3736 ### Information Displayed
@@ -40,22 +39,19 @@ Container Status:
4039 |-------|-------------|
4140 | Gordon Domain | Public Gordon domain from configuration |
4241 | Registry Port | Docker registry port |
43-| Server Port | Gordon HTTP proxy port |
44-| Routes | Total configured routes |
45-| Auto-Route | Whether auto-routing is enabled |
46-| Network Isolation | Whether network isolation is enabled |
47-| Container Status | Status of each route's container |
42+| Server Port | Gordon admin port |
43+| Routes | Total apps in desired/active state |
44+| Network Isolation | Whether installation network policy is enabled |
45+| Container Status | Fleet status per app (see states below) |
4846
49-### Container States
47+### App States
5048
5149 | State | Description |
5250 |-------|-------------|
53-| running | Container is running and healthy |
54-| restarting | Container is in runtime restart backoff or restart cycle |
55-| stopped | Container was stopped |
56-| exited | Container exited (check logs for errors) |
57-| paused | Container is paused |
58-| unknown | Unable to determine container state |
51+| active | App deployed and converged on desired state |
52+| deploying | Desired state diverges from effective state |
53+| pending | App applied but never deployed |
54+| stopped | Durable stopped intent (stays stopped across reboot) |
5955
6056 ## Flags
6157
@@ -90,11 +86,11 @@ export GORDON_TOKEN=your-token
9086 gordon status
9187 ```
9288
93-### Quick Health Check
89+### Quick Fleet Check
9490
9591 ```bash
96-# Check if all containers are running
97-gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"
92+# Check for non-converged apps
93+gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)"
9894 ```
9995
10096 ## Required Permissions (Remote Only)
@@ -109,5 +105,6 @@ gordon auth token generate --subject admin --scopes admin:status:read
109105 ## Related
110106
111107 - [Serve Command](./serve.md)
112-- [Routes Command](./routes.md)
108+- [Apps Commands](./apps.md)
109+- [CLI Overview](./index.md)
113110 - [Remote CLI Management](/wiki/guides/remote-cli.md)
M docs/cli/tls.md +0 -1
@@ -111,4 +111,3 @@ The `token_source` field indicates where the ACME token was sourced from
111111
112112 - [CLI Overview](./index.md)
113113 - [Status Command](./status.md)
114-- [Routes Command](./routes.md)
M docs/cli/volumes.md +14 -2
@@ -19,7 +19,7 @@ gordon volumes list --json
1919
2020 ### `gordon volumes prune`
2121
22-Remove orphaned volumes no longer associated with a running container.
22+Remove app-owned volumes that were explicitly released and are no longer used by any container.
2323
2424 ```bash
2525 gordon volumes prune
@@ -29,10 +29,22 @@ gordon volumes prune --no-confirm
2929
3030 | Flag | Description |
3131 |------|-------------|
32-| `--dry-run` | Show what would be removed without deleting |
32+| `--dry-run` | Report the plan without deleting |
3333 | `--no-confirm` | Skip confirmation prompt |
3434 | `--json` | Output as JSON |
3535
36+#### What survives
37+
38+A volume is removed only when all of the following hold:
39+
40+- A durable ownership record explicitly marks it `released`.
41+- The runtime labels agree with that record: the app, the app incarnation UUID, and the service.
42+- No container mounts it.
43+
44+Everything else survives. Attached and retained volumes, volumes carrying only `gordon.managed`, volumes with no Gordon provenance, and volumes whose ownership cannot be established all survive. Ownership is never inferred from the volume name, and labels alone never make a volume deletable.
45+
46+With current metadata no lifecycle marks a volume `released`, so a volume prune normally reports a zero-deletion success. The command still lists every candidate with its verdict and reason, and `--json` returns the same plan shape as `gordon images prune`.
47+
3648 ## Related
3749
3850 - [Volumes Configuration](../config/volumes.md)
M docs/concepts.md +99 -186
@@ -9,276 +9,189 @@ Your development machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2
99 Gordon flips the typical deployment model:
1010
1111 1. **Build locally** where you have computing power
12-2. **Push the finished image** to your VPS
13-3. **Gordon deploys automatically**
12+2. **Push the finished image** to your VPS registry
13+3. **Apply the app manifest** declaring the image
14+4. **Deploy** to activate it
1415
1516 This means faster builds, less VPS resource usage, and a simpler deployment workflow.
1617
17-## Push-to-Deploy
18+## Push, Apply, Deploy
1819
19-Gordon combines a Docker registry with automatic deployment:
20+Gordon combines a Docker registry with a declarative app runtime:
2021
2122 ```
2223 ┌──────────────┐ push ┌──────────────┐
2324 │ docker build │ ──────────────>│ Gordon │
24-│ docker push │ │ Registry │
25-└──────────────┘ └──────┬───────┘
25+│ docker push │ (OCI only, │ Registry │
26+└──────────────┘ never deploys)└──────┬───────┘
2627 │
27- │ event: image.pushed
28+ ┌───────────────────┴───────────────────┐
29+ │ gordon apps apply --file blog.toml │ desired state
30+ │ gordon apps deploy blog │ activation
31+ └───────────────────┬───────────────────┘
2832 v
2933 ┌──────────────┐
30- │ Deploy │
31- │ Container │
34+ │ App │
35+ │ Containers │
3236 └──────────────┘
3337 ```
3438
35-When you push an image, Gordon:
39+Pushing an image only stores it. Nothing runs, no route is created, no manifest is modified. Activation is always an explicit `gordon apps deploy`.
3640
37-1. Stores the image in its registry
38-2. Fires an `image.pushed` event
39-3. Looks up the route for that image
40-4. Deploys a new container
41-5. Updates the proxy routing
42-6. Stops the old container
41+## Declarative Apps
4342
44-## Zero-Downtime Updates
43+One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities.
4544
46-Gordon ensures your app stays available during updates:
47-
48-1. **New container starts** while old container is still running
49-2. **Health check** waits for new container to be ready
50-3. **Traffic switches** to the new container
51-4. **Old container stops** after traffic has moved
52-
53-```
54-Time ─────────────────────────────────────────────>
55-
56-Old Container: [═══════════════════]
57- ↓ stop
58-New Container: [═════════════════════════>
59- ↑ start ↑ traffic routed
60-```
61-
62-## Deletion and Cleanup Lifecycle
63-
64-Gordon separates configuration removal from destructive data cleanup:
65-
66-- **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`.
67-- **active** — an entity has runtime state, such as a running Gordon-managed container.
68-- **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data.
69-- **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis.
70-- **purged** — state explicitly deleted by a destructive cleanup command.
71-- **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later.
72-
73-Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion.
74-
75-Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time.
76-
77-Current runtime cleanup capabilities are intentionally conservative:
45+```toml
46+name = "blog"
7847
79-- Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`.
80-- Gordon can stop and remove containers.
81-- Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category.
82-- Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added.
48+[env]
49+APP_ENV = "production" # app-wide public env, injected into all services
8350
84-## Routes
51+[[service]]
52+name = "web"
53+image = "gordon.mydomain.com/blog:1.4.2"
8554
86-Routes map domains to container images:
55+[[service.http]]
56+host = "blog.mydomain.com"
57+port = 3000
8758
88-```toml
89-[routes]
90-"app.example.com" = { image = "myapp:latest" }
91-"api.example.com" = { image = "myapi:v2.1.0" }
59+[service.secrets] # ENV name -> secret name (values stay in pass)
60+DATABASE_URL = "database-url"
9261 ```
9362
94-Route domains must be plain hostnames such as `app.example.com`. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals. Legacy `http://...` keys are still read for backward compatibility and rewritten on the next save.
63+- `apps apply --file FILE` validates and persists desired configuration only.
64+- `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply.
65+- `--dry-run` validates and previews without persistence or runtime effects.
66+- Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content.
67+- The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential.
68+- Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command.
9569
96-When a request comes in for `app.example.com`, Gordon:
97-
98-1. Looks up the route configuration
99-2. Finds the running container for `myapp:latest`
100-3. Proxies the request to that container
70+## Updates
10171
102-### Route Domains
72+For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains with a bounded deadline, and retires that service's old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged.
10373
104-Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when `[tls.acme]` is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes.
74+```
75+Time ─────────────────────────────────────────────>
10576
106-```toml
107-[routes]
108-"dev-app.example.com" = { image = "internal-app:latest", https = false }
77+Old Container: [═══════════════════]
78+ ↓ retire
79+New Container: [═════════════════════════>
80+ ↑ start ↑ traffic routed
10981 ```
11082
111-## Network Isolation
83+TCP, UDP, mixed, and volume-owning services replace with interruption — no zero-downtime promise. An open UDP socket is not application readiness. Gordon never restarts an old volume-owning image automatically after a replacement may have written data.
11284
113-Each app runs in its own isolated Docker network:
85+## Deletion and Cleanup Lifecycle
11486
115-```
116-┌────────────────────────────────────────────────┐
117-│ gordon-app-mydomain-com │
118-│ │
119-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
120-│ │ App │───>│ Postgres │ │ Redis │ │
121-│ │ :3000 │ │ :5432 │ │ :6379 │ │
122-│ └──────────┘ └──────────┘ └──────────┘ │
123-│ │
124-└────────────────────────────────────────────────┘
125-```
87+Gordon separates workload removal from destructive data cleanup:
12688
127-Benefits:
89+- **desired** — the persisted manifest revision waiting to be activated.
90+- **active** — the pinned, running definition (never inferred from containers).
91+- **stopped** — durable stopped intent: workloads are down, data preserved, reboot keeps them stopped.
92+- **retained** — volumes and secrets kept after app removal under the old internal UUID, visible but never implicitly adopted by a new app reusing the name.
12893
129-- Containers can't access each other's services
130-- Services are only accessible by name within their network
131-- No port conflicts between apps
94+Safe removal is the default. `gordon apps remove` withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no `purge`: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes.
13295
133-## Attachments
96+## App HTTP Hosts
13497
135-Attachments are service dependencies for your apps:
98+An app's HTTP interfaces declare the hosts Gordon serves:
13699
137100 ```toml
138-[attachments]
139-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
101+[[service.http]]
102+host = "app.example.com"
103+port = 3000
140104 ```
141105
142-Gordon deploys attachments to the same network as your app. Services are accessible by their image name:
106+When a request comes in for `app.example.com`, Gordon:
107+
108+1. Looks up the host in the ACTIVE projection (merged with installation external routes)
109+2. Finds the recorded loopback backend for that host (never a container IP — rootless-first)
110+3. Proxies the request to that backend
143111
144-```javascript
145-// In your app
146-const db = await connect("postgresql://postgres:5432/mydb");
147-const cache = await connect("redis://redis:6379");
148-```
112+Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`).
149113
150-## Network Groups
114+## Networks
151115
152-Network groups allow multiple apps to share services:
116+Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership:
153117
154118 ```toml
155-[network_groups]
156-"backend" = ["app.mydomain.com", "api.mydomain.com"]
157-
158-[attachments]
159-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
119+[[network.shared]]
120+network = "backend"
121+services = ["web", "worker"]
160122 ```
161123
162-Both `app.mydomain.com` and `api.mydomain.com` can access the shared services.
124+Deploy adds AND removes memberships without disconnecting unrelated services. Short DNS names resolve privately; app-qualified aliases apply on shared networks.
163125
164126 ## Volumes
165127
166-Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives:
128+Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes:
167129
168-```dockerfile
169-FROM postgres:18
170-VOLUME ["/var/lib/postgresql/data"]
130+```toml
131+[[service.volume]]
132+name = "web-data"
133+path = "/data"
171134 ```
172135
173-Volume behavior:
136+Docker/Podman own the named volumes; Gordon records app UUID, service, and logical volume ownership. App manifests allow neither bind mounts nor service-shared volumes. Replacement and restart reuse volumes. Removing a service or app retains its volumes under the original app UUID, and a new app reusing the public name never adopts them.
174137
175-- **auto_create**: Volumes are created automatically (default: true)
176-- **prefix**: Volume names are prefixed with `gordon-` (configurable)
177-- **preserve**: Volumes persist across container updates (default: true)
138+Use `gordon volumes prune --dry-run` to inspect Gordon's ownership-aware plan. Do not use `docker volume prune` or an equivalent runtime command for Gordon data: it bypasses Gordon's retention checks and can delete unmounted retained volumes.
178139
179-## Environment Variables
140+## Environment and Secrets
180141
181-Gordon loads environment variables from files based on the domain:
142+App-wide public env is declared in the manifest:
182143
183-```
184-~/.gordon/env/
185-├── app_mydomain_com.env
186-├── api_mydomain_com.env
187-└── admin_mydomain_com.env
144+```toml
145+[env]
146+APP_ENV = "production"
188147 ```
189148
190-Domain dots become underscores: `app.mydomain.com` → `app_mydomain_com.env`
149+Service-specific values use `secrets` even when non-confidential:
191150
192-Variables are merged in order:
193-
194-1. Dockerfile `ENV` directives (lowest priority)
195-2. `.env` file values (highest priority)
196-
197-### Secret Providers
198-
199-Environment files support secret provider syntax:
200-
201-```bash
202-# From Unix password manager (pass)
203-DATABASE_PASSWORD=${pass:myapp/db-password}
204-
205-# From SOPS encrypted files
206-API_SECRET=${sops:secrets.yaml:api.secret}
151+```toml
152+[service.secrets]
153+DATABASE_URL = "database-url"
207154 ```
208155
209-## Configuration Hot-Reload
210-
211-Gordon watches its config file and reloads automatically:
212-
213-1. Edit `~/.config/gordon/gordon.toml`
214-2. Save the file
215-3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
216-4. Containers and proxy config sync to match the new configuration
156+Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by the stable internal UUID so a removed app's secrets are never adopted by a new app reusing the name. Secret updates affect the next deploy/restart, not running containers. Write values with `gordon apps secrets set`; only key names are ever echoed back, never values.
217157
218-Route file edits now reload automatically again.
158+## Installation Reload
219159
220-You can also trigger a reload:
160+Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads.
221161
222162 ```bash
223163 gordon reload
224164 ```
225165
226-`gordon reload` sends `SIGUSR1` to the running Gordon process.
166+`gordon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files.
227167
228168 ## Event System
229169
230-Gordon uses an internal event system for coordination:
231-
232-| Event | Trigger | Action |
233-|-------|---------|--------|
234-| `image.pushed` | Image pushed to registry | Deploy container |
235-| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
236-| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
237-| `container.deployed` | Container started | Update proxy cache |
170+Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests.
238171
239172 ## Backups and Recovery
240173
241-Gordon can run logical PostgreSQL backups for attachment containers.
242-
243-Current design:
174+Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[service.database]]`, `[service.backup]`); storage infrastructure (destinations, schedules, retention) stays global.
244175
245-1. Detect PostgreSQL attachments attached to a route
246-2. Execute `pg_dump -Fc` through Gordon runtime operations
247-3. Store backup artifacts on local filesystem storage
248-4. Expose backup actions through admin API and CLI
176+Stored backups are never deleted when declarations change — only schedules update on deploy.
249177
250-This is intentionally scoped for operational safety and predictable behavior.
251-Future extensions can add physical backups, PITR, and remote object storage adapters.
252178 For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md).
253179
254-## Container Labels
180+## Container Identity
255181
256-Gordon uses labels to track managed containers:
182+Gordon stamps app ownership labels on every container and volume it creates:
257183
258184 | Label | Purpose |
259185 |-------|---------|
260-| `gordon.managed=true` | Identifies Gordon-managed containers |
261-| `gordon.domain` | Domain this container serves |
262-| `gordon.image` | Image name and tag |
263-| `gordon.route` | Route this container handles |
264-| `gordon.attachment=true` | Container is an attachment service |
265-| `gordon.attached-to` | Which route this attachment serves |
266-
267-## Proxy Port Selection
268-
269-When a container exposes multiple ports, Gordon needs to know which one serves HTTP:
270-
271-```dockerfile
272-FROM gitea/gitea:latest
273-LABEL gordon.proxy.port=3000 # Route HTTP to port 3000
274-EXPOSE 22 # SSH
275-EXPOSE 3000 # HTTP
276-```
186+| `gordon.managed=true` | Identifies Gordon-managed resources |
187+| `gordon.app` | App public name |
188+| `gordon.app.service` | Service name |
189+| `gordon.app.revision` | Active revision that created it |
277190
278-Without the label, Gordon uses the first exposed port.
191+Queries by logical identity use labels, never name parsing. Unknown resources (no labels, old labels) are preserved, never adopted or deleted.
279192
280193 ## Related
281194
282195 - [Configuration Reference](./config/index.md)
283-- [Docker Labels Reference](./reference/docker-labels.md)
284-- [Environment Variables](./config/env.md)
196+- [Apps CLI](./cli/apps.md)
197+- [Getting Started](./getting-started.md)
A docs/config/apps.md +132 -0
@@ -0,0 +1,132 @@
1+# App Manifest
2+
3+Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`.
4+
5+The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential; values stay in pass.
6+
7+## Minimal Example
8+
9+```toml
10+name = "blog"
11+
12+[[service]]
13+name = "web"
14+image = "gordon.mydomain.com/blog:1.4.2"
15+
16+[[service.http]]
17+host = "blog.mydomain.com"
18+port = 3000
19+```
20+
21+## Full Example
22+
23+```toml
24+name = "blog"
25+
26+[env] # optional, app-wide public env for all services
27+APP_ENV = "production"
28+
29+[[service]]
30+name = "web"
31+image = "gordon.mydomain.com/blog:1.4.2"
32+command = ["node", "server.js"] # optional override
33+stop_grace = "10s" # optional, default 10s
34+
35+[service.readiness] # optional explicit readiness
36+type = "http"
37+path = "/healthz"
38+port = 3000
39+timeout = "30s"
40+
41+[[service.http]] # 0..n HTTP interfaces
42+host = "blog.mydomain.com"
43+port = 3000
44+tls = "auto" # auto | always | never
45+
46+[service.secrets] # ENV name -> secret name (values in pass)
47+DATABASE_URL = "database-url"
48+
49+[[service.volume]] # 0..n named volumes only
50+name = "web-data"
51+path = "/data"
52+readonly = false
53+
54+[[service.database]] # explicit database declarations
55+name = "main"
56+type = "postgres"
57+schedule = "daily"
58+
59+[service.backup] # backup targets by reference
60+postgres = ["main"]
61+volume = ["web-data"]
62+
63+[[network.shared]] # optional shared-network memberships
64+network = "backend"
65+services = ["web"]
66+```
67+
68+## Identity Rules
69+
70+- App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness.
71+- Service name: `[a-z0-9_.-]`, max 63, unique within the app.
72+- Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes.
73+
74+## Services
75+
76+One or more explicitly named image-backed services per app. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none.
77+
78+```toml
79+[[service.tcp]]
80+entrypoint = "game" # names installation entrypoints.<name>
81+port = 25565
82+publish = "0.0.0.0:25565"
83+
84+[[service.udp]]
85+entrypoint = "game"
86+port = 28015
87+publish = "0.0.0.0:28015"
88+```
89+
90+RCON is ordinary TCP: use TCP interfaces, no special RCON kind.
91+
92+`publish` must match the entrypoint listener it attaches to exactly: the same host, port, and transport. The traffic manager binds the entrypoint address, so a declaration that differs (for example a loopback bind on a wildcard entrypoint, or a different port) is rejected at apply time instead of being silently widened. `0.0.0.0` and an omitted host are the same wildcard.
93+
94+Image registry names and digest syntax are validated during manifest apply, resolution, deployment preflight, and immediately before every pull, including boot, restart, and recovery. Docker Hub (`docker.io` and canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are allowed by default. Add other exact registry hostname+port entries with `images.allowed_registries`; this does not configure credentials, and external resolution and pulls use anonymous access. When `images.require_digest` is enabled, every image reference, including Gordon registry images, must use `@sha256:<64 hex chars>`. See [Images](./images.md). This is a hostname allowlist, not DNS/IP validation or runtime egress enforcement.
95+
96+## Environment and Secrets
97+
98+- `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[service.secrets]` key in the app.
99+- `[service.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Secret updates affect the next deploy/restart, not running containers.
100+- There is no `[service.env]` key — service-specific values must use secrets.
101+
102+## Volumes and Databases
103+
104+- Named volumes only; no bind mounts, no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible.
105+- A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
106+- `[[service.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).
107+- `[service.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change.
108+
109+## Readiness
110+
111+`type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation.
112+
113+## TLS
114+
115+- `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled.
116+- `always` is enforced: a plaintext request to an `always` host is redirected whenever an HTTPS endpoint exists, and refused with `421 Misdirected Request` when none does. The backend is never reached over plaintext.
117+- `never` stays plaintext and is never issued an app certificate.
118+
119+## Networks
120+
121+Each app gets a private network automatically. `[[network.shared]]` adds services to named shared networks, created/reused only within verified Gordon ownership. Deploy adds AND removes memberships without disconnecting unrelated services.
122+
123+## Strictness
124+
125+Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence.
126+
127+## Related
128+
129+- [Apps CLI](../cli/apps.md)
130+- [Secrets](./secrets.md)
131+- [Volumes](./volumes.md)
132+- [External Routes](./external-routes.md)
D docs/config/attachments.md +0 -315
@@ -1,315 +0,0 @@
1-# Attachments Configuration
2-
3-Attach service dependencies (databases, caches, queues) to your applications.
4-
5-## Requirements
6-
7-**Network isolation must be enabled** for attachments to work properly:
8-
9-```toml
10-[network_isolation]
11-enabled = true
12-```
13-
14-Without network isolation, containers run on Docker's default bridge network which **does not provide DNS resolution**. Your application won't be able to reach attachments by hostname (e.g., `postgres:5432`).
15-
16-## Configuration
17-
18-```toml
19-[attachments]
20-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
21-"api.mydomain.com" = ["postgres:latest"]
22-```
23-
24-## Syntax
25-
26-```toml
27-[attachments]
28-"<domain-or-group>" = ["<image>:<tag>", "<image>:<tag>"]
29-```
30-
31-| Component | Description |
32-|-----------|-------------|
33-| `domain-or-group` | Route domain or network group name |
34-| `image:tag` | Service images to deploy alongside the app |
35-
36-## How Attachments Work
37-
38-When you define attachments:
39-
40-1. Gordon deploys attachment containers to the same network as your app
41-2. Services are accessible by their image name (before the colon)
42-3. Attachments start before your main application
43-4. Attachments persist across app updates
44-
45-```
46-┌───────────────────────────────────────────────────┐
47-│ Network: gordon-app-mydomain-com │
48-│ │
49-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
50-│ │ App │───>│ postgres │ │ redis │ │
51-│ │ :3000 │ │ :5432 │ │ :6379 │ │
52-│ └──────────┘ └──────────┘ └──────────┘ │
53-│ │
54-└───────────────────────────────────────────────────┘
55-```
56-
57-## Service Discovery
58-
59-Attachments are accessible by their image name within the network:
60-
61-```toml
62-[attachments]
63-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
64-```
65-
66-In your application:
67-```javascript
68-// Connect using simple hostnames
69-const db = await pg.connect("postgresql://postgres:5432/mydb");
70-const cache = await redis.connect("redis://redis:6379");
71-```
72-
73-## Persistent Storage
74-
75-Use Dockerfile VOLUME directives for persistent data:
76-
77-```dockerfile
78-# postgres.Dockerfile
79-FROM postgres:18
80-VOLUME ["/var/lib/postgresql/data"]
81-ENV POSTGRES_DB=myapp
82-ENV POSTGRES_USER=app
83-# Password is injected via attachment secrets — do NOT hardcode here
84-```
85-
86-Configure credentials via attachment secrets instead of hardcoding them:
87-
88-```bash
89-# Set database password securely
90-gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
91-
92-# Verify
93-gordon secrets list app.mydomain.com
94-```
95-
96-This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details.
97-
98-Build and push to Gordon:
99-```bash
100-docker build -f postgres.Dockerfile -t my-postgres:latest .
101-docker push registry.mydomain.com/my-postgres:latest
102-```
103-
104-Use in attachments:
105-```toml
106-[attachments]
107-"app.mydomain.com" = ["my-postgres:latest"]
108-```
109-
110-## Attachment Secrets
111-
112-Inject environment variables into attachment containers using the `--attachment` flag on secrets commands:
113-
114-```bash
115-# Set secrets for the postgres attachment
116-gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
117-
118-# Set secrets for the redis attachment
119-gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret
120-
121-# View all secrets (domain + attachments) in a tree view
122-gordon secrets list app.mydomain.com
123-
124-# Remove an attachment secret
125-gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
126-```
127-
128-The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`.
129-
130-Storage depends on your secrets backend:
131-- **pass**: `gordon/env/attachments/<container-name>/<KEY>`
132-- **sops/unsafe**: `gordon-<container-name>.env` files
133-
134-See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details.
135-
136-## Shared Attachments with Network Groups
137-
138-Share services between multiple apps using network groups:
139-
140-```toml
141-[network_groups]
142-"backend" = ["app.mydomain.com", "api.mydomain.com"]
143-
144-[attachments]
145-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
146-```
147-
148-Both `app.mydomain.com` and `api.mydomain.com` can access the shared services.
149-
150-## Per-App vs Shared Attachments
151-
152-### Per-App Attachments
153-
154-Each app gets its own isolated service instances:
155-
156-```toml
157-[attachments]
158-"app.mydomain.com" = ["postgres:latest"] # App's own postgres
159-"api.mydomain.com" = ["postgres:latest"] # API's own postgres
160-```
161-
162-### Shared Attachments
163-
164-Multiple apps share the same service instances:
165-
166-```toml
167-[network_groups]
168-"backend" = ["app.mydomain.com", "api.mydomain.com"]
169-
170-[attachments]
171-"backend" = ["postgres:latest", "redis:latest"] # Shared by both
172-```
173-
174-### Mixed Approach
175-
176-```toml
177-[network_groups]
178-"backend" = ["app.mydomain.com", "api.mydomain.com"]
179-
180-[attachments]
181-"backend" = ["redis:latest"] # Shared cache
182-"app.mydomain.com" = ["postgres:latest"] # App's own database
183-"api.mydomain.com" = ["postgres:latest"] # API's own database
184-```
185-
186-## Examples
187-
188-### Web App with Database
189-
190-```toml
191-[routes]
192-"app.mydomain.com" = "myapp:latest"
193-
194-[attachments]
195-"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]
196-```
197-
198-### Microservices with Shared Queue
199-
200-```toml
201-[routes]
202-"orders.mydomain.com" = "orders-service:latest"
203-"inventory.mydomain.com" = "inventory-service:latest"
204-"notifications.mydomain.com" = "notifications-service:latest"
205-
206-[network_groups]
207-"services" = ["orders.mydomain.com", "inventory.mydomain.com", "notifications.mydomain.com"]
208-
209-[attachments]
210-"services" = ["rabbitmq:3-management"]
211-"orders.mydomain.com" = ["orders-db:latest"]
212-"inventory.mydomain.com" = ["inventory-db:latest"]
213-```
214-
215-### Custom Service Images
216-
217-Build custom service images with your configuration:
218-
219-```dockerfile
220-# my-postgres.Dockerfile
221-FROM postgres:18
222-VOLUME ["/var/lib/postgresql/data"]
223-ENV POSTGRES_DB=production
224-ENV POSTGRES_USER=appuser
225-# Password should come from env file
226-```
227-
228-```dockerfile
229-# my-redis.Dockerfile
230-FROM redis:7-alpine
231-VOLUME ["/data"]
232-CMD ["redis-server", "--appendonly", "yes"]
233-```
234-
235-```toml
236-[attachments]
237-"app.mydomain.com" = ["my-postgres:latest", "my-redis:latest"]
238-```
239-
240-## Container Naming
241-
242-Attachment containers are named:
243-- `gordon-<domain>-<image-name>`
244-- Example: `gordon-app-mydomain-com-postgres`
245-
246-## Labels
247-
248-Gordon adds labels to attachment containers:
249-
250-| Label | Value |
251-|-------|-------|
252-| `gordon.managed` | `true` |
253-| `gordon.attachment` | `true` |
254-| `gordon.attached-to` | Domain or group name |
255-
256-## CLI Management
257-
258-Manage attachments via the CLI without editing configuration files.
259-
260-### List Attachments
261-
262-```bash
263-# List all attachments
264-gordon attachments list
265-
266-# List attachments for a specific domain or network group
267-gordon attachments list app.mydomain.com
268-gordon attachments list backend
269-
270-# Remote mode
271-gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
272-```
273-
274-### Add Attachments
275-
276-```bash
277-# Add attachment to a domain
278-gordon attachments add app.mydomain.com postgres:18
279-
280-# Add attachment to a network group
281-gordon attachments add backend redis:7-alpine
282-
283-# Remote mode
284-gordon attachments add app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
285-```
286-
287-### Remove Attachments
288-
289-```bash
290-# Remove attachment from a domain
291-gordon attachments remove app.mydomain.com postgres:18
292-
293-# Remove from network group
294-gordon attachments remove backend redis:7-alpine
295-
296-# Remote mode
297-gordon attachments remove app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
298-```
299-
300-### Alias
301-
302-The `gordon attach` command is an alias for `gordon attachments`:
303-
304-```bash
305-gordon attach list
306-gordon attach add app.mydomain.com postgres:18
307-gordon attach remove app.mydomain.com postgres:18
308-```
309-
310-## Related
311-
312-- [Network Isolation](./network-isolation.md)
313-- [Network Groups](./network-groups.md)
314-- [Routes](./routes.md)
315-- [CLI Commands](/docs/cli/index.md)
M docs/config/auth.md +12 -3
@@ -87,8 +87,8 @@ Admin scopes (for remote CLI):
8787 | Scope | Permission |
8888 |-------|------------|
8989 | `admin:*:*` | Full admin access |
90-| `admin:routes:read` | Read-only routes access |
91-| `admin:routes:write` | Routes write access |
90+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
91+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
9292 | `admin:config:read` | Read-only config access |
9393 | `admin:config:write` | Config write access |
9494 | `admin:status:read` | Read-only status/health |
@@ -143,10 +143,19 @@ gordon auth token generate --subject temp --expiry 30d
143143
144144 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
145145
146-- `/admin/*` endpoints are not registered.
146+- `/admin/*` endpoints are not registered on the TCP listener.
147147 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
148148 - Remote registry and remote admin access are disabled.
149149
150+Local `gordon apps` commands keep working through an owner-only administration socket:
151+
152+- When `XDG_RUNTIME_DIR` is set, the daemon uses `$XDG_RUNTIME_DIR/gordon/admin.sock`; an invalid or unsafe preferred path fails closed and never falls back. When `XDG_RUNTIME_DIR` is unset, the daemon uses `~/.gordon/run/admin.sock`. The CLI probes `$XDG_RUNTIME_DIR/gordon/admin.sock` when set, then `/run/user/<uid>/gordon/admin.sock`, then `~/.gordon/run/admin.sock`, accepting only a safe owner-owned socket.
153+- The runtime directory is `0700` and the socket `0600`, both owned by the effective user. A foreign owner, group/other permission bits, or a symlink makes the daemon refuse to start and the CLI refuse to connect; an existing live listener is never replaced.
154+- The socket exposes only app administration and app log reads. The implicit `local-owner` principal receives exactly `admin:apps:read`, `admin:apps:write`, and `admin:logs:read`; configuration, auth, prune, route, backup, and volume routes are denied.
155+- Unix platforms only. Never share, mount, or forward the socket: filesystem access to it is the credential.
156+
157+With neither a running daemon nor an explicit remote, app commands fail with `daemon-unavailable`. An explicit `--remote`/`GORDON_REMOTE` is authoritative and never falls back to the local socket.
158+
150159 Use this only when Gordon is intended for local machine usage.
151160
152161 ## Internal Registry Auth
D docs/config/auto-route.md +0 -281
@@ -1,281 +0,0 @@
1-# Auto Route Configuration
2-
3-Gordon supports two methods for automatic route creation:
4-
5-1. **Image Name Detection** - Routes from domain-like image names
6-2. **Image Labels** - Routes from Dockerfile labels (recommended)
7-
8-## Configuration
9-
10-```toml
11-[auto_route]
12-enabled = true
13-```
14-
15-## Options
16-
17-| Option | Type | Default | Description |
18-|--------|------|---------|-------------|
19-| `enabled` | bool | `false` | Enable automatic route creation |
20-
21-## How It Works
22-
23-When auto-route is enabled, Gordon automatically creates routes for images with domain-like names:
24-
25-```bash
26-# Push an image with a domain name
27-docker push registry.mydomain.com/app.example.com:latest
28-```
29-
30-Gordon automatically:
31-1. Detects `app.example.com` looks like a domain
32-2. Creates a route: `app.example.com` → `app.example.com:latest`
33-3. Deploys the container
34-4. Routes traffic to it
35-
36-## Domain Detection
37-
38-Gordon recognizes image names as domains when they:
39-
40-- Contain at least one dot (`.`)
41-- Don't start or end with a dot
42-- Look like valid hostnames
43-
44-| Image Name | Detected as Domain? |
45-|------------|-------------------|
46-| `app.example.com` | Yes |
47-| `myapp.dev` | Yes |
48-| `staging.api.io` | Yes |
49-| `myapp` | No (no dots) |
50-| `.invalid` | No (starts with dot) |
51-| `myapp.` | No (ends with dot) |
52-
53-## Use Cases
54-
55-### Simplified Deployment
56-
57-Without auto-route:
58-```toml
59-# Must define each route in config
60-[routes]
61-"app.example.com" = "app.example.com:latest"
62-```
63-
64-With auto-route:
65-```toml
66-[auto_route]
67-enabled = true
68-# No routes needed - just push!
69-```
70-
71-```bash
72-docker push registry.mydomain.com/app.example.com:latest
73-# Route automatically created
74-```
75-
76-### Development Workflow
77-
78-```bash
79-# Create a new app instantly
80-docker build -t myapp .
81-docker tag myapp registry.mydomain.com/newapp.mydomain.com:latest
82-docker push registry.mydomain.com/newapp.mydomain.com:latest
83-# newapp.mydomain.com is now live!
84-```
85-
86-### Multi-Tenant SaaS
87-
88-```bash
89-# Quickly provision customer subdomains
90-docker push registry.mydomain.com/acme.saas.com:latest
91-docker push registry.mydomain.com/beta.saas.com:latest
92-docker push registry.mydomain.com/gamma.saas.com:latest
93-```
94-
95-## Combining with Manual Routes
96-
97-Auto-route works alongside manual routes:
98-
99-```toml
100-[auto_route]
101-enabled = true
102-
103-[routes]
104-# Pinned versions override auto-route
105-"api.mydomain.com" = "myapi:v2.1.0"
106-```
107-
108-- `api.mydomain.com` uses the manually configured `myapi:v2.1.0`
109-- Other domain-named images create automatic routes
110-
111-## Priority Order
112-
113-1. **Manual routes** in `[routes]` take precedence
114-2. **Auto-routes** are created for domain-named images without manual routes
115-
116-## Examples
117-
118-### Enable Auto-Route
119-
120-```toml
121-[auto_route]
122-enabled = true
123-```
124-
125-### Development with Auto-Route
126-
127-```toml
128-[server]
129-gordon_domain = "gordon.local"
130-
131-[auto_route]
132-enabled = true
133-
134-# No routes defined - all come from image names
135-```
136-
137-Usage:
138-```bash
139-docker push gordon.local/myapp.local:latest
140-docker push gordon.local/api.local:latest
141-# Both routes created automatically
142-```
143-
144-### Production with Auto-Route
145-
146-```toml
147-[auto_route]
148-enabled = true
149-
150-[routes]
151-# Critical services with pinned versions
152-"api.company.com" = "company-api:v2.1.0"
153-"app.company.com" = "company-app:v1.5.0"
154-
155-# Other services can use auto-route
156-```
157-
158-## Limitations (Image Name Detection)
159-
160-- Auto-routes always use the pushed image tag
161-- No support for HTTP-only routes (all HTTPS)
162-- No automatic attachment configuration
163-- Cannot pin versions (always uses pushed tag)
164-
165-For more control, define routes manually in `[routes]` or use image labels.
166-
167----
168-
169-## Image Labels (Recommended)
170-
171-The preferred method for automatic routing uses Dockerfile labels. This gives you full control over routing behavior directly in your application.
172-
173-### Supported Labels
174-
175-| Label | Description | Example |
176-|-------|-------------|---------|
177-| `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` |
178-| `gordon.proxy.port` | Container port to proxy | `3000` |
179-| `gordon.health` | Health check endpoint path | `/health` |
180-| `gordon.env-file` | Path to .env file in image | `/app/.env.example` |
181-
182-### Dockerfile Example
183-
184-```dockerfile
185-FROM node:20-alpine
186-WORKDIR /app
187-COPY . .
188-RUN npm install
189-
190-# Gordon routing labels
191-LABEL gordon.domains="myapp.example.com,www.myapp.example.com"
192-LABEL gordon.proxy.port="3000"
193-LABEL gordon.health="/api/health"
194-LABEL gordon.env-file="/app/.env.example"
195-
196-EXPOSE 3000
197-CMD ["npm", "start"]
198-```
199-
200-### How It Works
201-
202-When you push an image with Gordon labels:
203-
204-1. Gordon reads the `gordon.domains` label
205-2. Creates routes for each domain automatically
206-3. Uses `gordon.proxy.port` for the proxy target, otherwise auto-detects an exposed port preferring common HTTP ports
207-4. Configures health checks if `gordon.health` is set
208-5. Extracts environment variables from `gordon.env-file` if specified
209-
210-### Label Priority
211-
212-Labels take precedence over image name detection:
213-
214-1. **Labels present** → Use `gordon.domains` for routing
215-2. **No labels** → Fall back to image name detection (if enabled)
216-3. **Manual routes** → Always take highest priority
217-
218-### Multi-Domain Example
219-
220-```dockerfile
221-# Single image serving multiple domains
222-LABEL gordon.domains="api.example.com,api.staging.example.com"
223-```
224-
225-Both domains will route to the same container.
226-
227-### Environment File Extraction
228-
229-The `gordon.env-file` label tells Gordon where to find a template `.env` file:
230-
231-```dockerfile
232-# Include a .env.example in your image
233-COPY .env.example /app/.env.example
234-LABEL gordon.env-file="/app/.env.example"
235-```
236-
237-Gordon will:
238-1. Extract the file from the image
239-2. Store it in the env directory
240-3. Use the variables for container deployment
241-
242-### Best Practices
243-
244-1. **Always set `gordon.proxy.port`** - Don't rely on EXPOSE detection
245-2. **Use `gordon.health`** - Enables reliable deployment verification
246-3. **Include `.env.example`** - Self-documenting environment requirements
247-4. **Separate domains with commas** - No spaces around commas
248-
249-### Example: Complete Setup
250-
251-```dockerfile
252-FROM golang:1.25-alpine AS builder
253-WORKDIR /app
254-COPY . .
255-RUN go build -o server
256-
257-FROM alpine:3.21
258-WORKDIR /app
259-COPY --from=builder /app/server .
260-COPY .env.example .
261-
262-# Gordon labels
263-LABEL gordon.domains="api.mycompany.com"
264-LABEL gordon.proxy.port="8080"
265-LABEL gordon.health="/health"
266-LABEL gordon.env-file="/app/.env.example"
267-
268-EXPOSE 8080
269-CMD ["./server"]
270-```
271-
272-```bash
273-# Push and Gordon handles everything
274-docker push registry.mycompany.com/api:latest
275-# Route automatically created: api.mycompany.com → container:8080
276-```
277-
278-## Related
279-
280-- [Routes](./routes.md)
281-- [Configuration Overview](./index.md)
M docs/config/backups.md +16 -7
@@ -5,6 +5,10 @@ Gordon has two backup flows:
55 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
66 - **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
77
8+Backups are identified by app, service, and the declared database or volume
9+(see [Apps](../config/apps.md)). Domains are routing addresses and are never a
10+backup identity.
11+
812 ## Database backups
913
1014 ```toml
@@ -64,19 +68,24 @@ keep = 14
6468 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
6569 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
6670 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
67-| `backups.volumes.retention.keep` | `14` | Completed archives to keep per domain + volume |
71+| `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |
6872
69-Volume backup objects are stored under:
73+Volume backup objects are stored under the canonical app name:
7074
7175 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
76+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
77+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
7478 ```
7579
80+Objects written by earlier versions under a domain prefix are not read or
81+migrated: back them up and re-run the app's volume backups if you need them in
82+the current layout.
83+
7684 ## Notes
7785
78-- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
79-- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.
86+- Volume backups archive the app's declared volumes that its backup declaration references.
87+- Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.
88+- Database backups archive the app's declared PostgreSQL databases that its backup declaration references.
8089 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
8190 - Automated volume restore is not part of the MVP.
8291 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
@@ -84,6 +93,6 @@ Volume backup objects are stored under:
8493
8594 ## Related
8695
87-- [Attachments Configuration](./attachments.md)
96+- [App Manifest](./apps.md)
8897 - [CLI Backup Command](../cli/backup.md)
8998 - [Configuration Reference](./reference.md)
M docs/config/env.md +9 -152
@@ -1,6 +1,6 @@
11 # Environment Variables
22
3-Configure per-application environment variables using domain-based files.
3+App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[service.secrets]` (values in pass, resolved at deploy). This page covers the installation `[env]` store location and the provider syntax used in env files.
44
55 ## Configuration
66
@@ -13,75 +13,17 @@ dir = "~/.gordon/env" # Default location
1313
1414 | Option | Type | Default | Description |
1515 |--------|------|---------|-------------|
16-| `dir` | string | `~/.gordon/env` | Directory containing .env files |
16+| `dir` | string | `~/.gordon/env` | Directory for the installation secret store |
1717
1818 ## How It Works
1919
20-Gordon loads environment variables from files named after the domain:
20+The `[env]` directory backs the installation secret store:
2121
22-```
23-~/.gordon/env/
24-├── app_mydomain_com.env # For app.mydomain.com
25-├── api_mydomain_com.env # For api.mydomain.com
26-└── admin_mydomain_com.env # For admin.mydomain.com
27-```
28-
29-**Naming convention:** Replace dots with underscores, add `.env` extension.
30-
31-| Domain | File Name |
32-|--------|-----------|
33-| `app.mydomain.com` | `app_mydomain_com.env` |
34-| `api.company.io` | `api_company_io.env` |
35-| `staging.app.dev` | `staging_app_dev.env` |
36-
37-## Backend Behavior
38-
39-The `auth.secrets_backend` setting changes where route secrets live:
40-
41-### Pass Backend
42-
43-- Env files are not used for route secrets.
44-- Use `gordon secrets set` to store per-domain secrets in pass.
45-- Existing `.env` files are migrated on startup and renamed to `.env.migrated`.
46-
47-### SOPS or Unsafe Backend
48-
49-- Env files remain the source of truth.
50-- Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
51-
52-## File Format
53-
54-Standard `.env` file format:
55-
56-```bash
57-# app_mydomain_com.env
58-NODE_ENV=production
59-PORT=3000
60-DATABASE_URL=postgresql://db:5432/myapp
61-API_KEY=sk-1234567890
62-DEBUG=false
63-```
64-
65-## Variable Merging
66-
67-Variables are merged from multiple sources (lowest to highest priority):
22+- With the `pass` backend, `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores). Env files are not used for secrets.
23+- With the `pass` backend, Gordon imports eligible plaintext `.env` files into pass at startup and removes each source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon leaves the source file in place for operator review.
24+- With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
6825
69-1. **Dockerfile ENV** - Base defaults from image
70-2. **.env file** - Overrides Dockerfile values
71-
72-```dockerfile
73-# Dockerfile
74-ENV NODE_ENV=development
75-ENV PORT=3000
76-```
77-
78-```bash
79-# app_mydomain_com.env
80-NODE_ENV=production # Overrides Dockerfile
81-# PORT not set, uses 3000 from Dockerfile
82-```
83-
84-Result: `NODE_ENV=production`, `PORT=3000`
26+App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[service.secrets]` at deploy time (see [App Manifest](./apps.md)).
8527
8628 ## Secret Provider Syntax
8729
@@ -110,98 +52,13 @@ STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
11052 | pass | `${pass:<path>}` | `${pass:myapp/db-password}` |
11153 | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` |
11254
113-## Examples
114-
115-### Basic Application
116-
117-```bash
118-# app_mydomain_com.env
119-NODE_ENV=production
120-PORT=3000
121-LOG_LEVEL=info
122-```
123-
124-### Database Connection
125-
126-```bash
127-# ~/.gordon/env/api_mydomain_com.env
128-DATABASE_HOST=postgres
129-DATABASE_PORT=5432
130-DATABASE_NAME=myapi
131-DATABASE_USER=apiuser
132-DATABASE_PASSWORD=${pass:myapi/db-password}
133-DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
134-```
135-
136-### Full Production Setup
137-
138-```bash
139-# ~/.gordon/env/app_company_com.env
140-# Application settings
141-NODE_ENV=production
142-PORT=3000
143-LOG_LEVEL=warn
144-
145-# Database
146-DATABASE_URL=postgresql://postgres:5432/production
147-DATABASE_PASSWORD=${pass:company/db-password}
148-
149-# Redis
150-REDIS_URL=redis://redis:6379
151-
152-# External APIs
153-STRIPE_SECRET_KEY=${pass:company/stripe-secret}
154-SENDGRID_API_KEY=${pass:company/sendgrid-key}
155-
156-# Auth
157-JWT_SECRET=${pass:company/jwt-secret}
158-SESSION_SECRET=${pass:company/session-secret}
159-
160-# Feature flags
161-ENABLE_ANALYTICS=true
162-MAINTENANCE_MODE=false
163-```
164-
165-### Development Environment
166-
167-```bash
168-# ~/.gordon/env/app_local.env
169-NODE_ENV=development
170-PORT=3000
171-LOG_LEVEL=debug
172-DATABASE_URL=postgresql://postgres:5432/dev
173-DEBUG=*
174-```
175-
17655 ## File Permissions
17756
178-Gordon creates env files with secure permissions:
57+The env directory uses secure permissions:
17958 - Directory: `0700` (owner only)
18059 - Files: `0600` (owner only)
18160
182-Create files with proper permissions:
183-
184-```bash
185-mkdir -p ~/.gordon/env
186-chmod 700 ~/.gordon/env
187-touch ~/.gordon/env/app_mydomain_com.env
188-chmod 600 ~/.gordon/env/app_mydomain_com.env
189-```
190-
191-## Auto-Creation
192-
193-Gordon creates empty env files automatically when deploying a new route. You can then edit the file to add your variables.
194-
195-## Viewing Effective Environment
196-
197-To see what environment a container receives:
198-
199-```bash
200-docker inspect gordon-app-mydomain-com | jq '.[0].Config.Env'
201-```
202-
20361 ## Related
20462
20563 - [Secrets Configuration](./secrets.md)
206-- [Routes](./routes.md)
207-- [Attachments](./attachments.md)
64+- [App Manifest](./apps.md)
M docs/config/external-routes.md +1 -1
@@ -79,5 +79,5 @@ vim ~/.config/gordon/gordon.toml
7979
8080 ## Related
8181
82-- [Routes Configuration](./routes.md)
82+- [App Manifest](./apps.md)
8383 - [Configuration Overview](./index.md)
M docs/config/images.md +21 -6
@@ -6,18 +6,20 @@ Configure automatic image cleanup for Docker runtime images and local registry s
66
77 When enabled, Gordon runs a scheduled image prune job that:
88
9-- Prunes dangling runtime images.
9+- Prunes dangling runtime images that carry positive Gordon provenance.
1010 - Applies tag retention to registry repositories.
1111 - Preserves the `latest` tag.
1212 - Removes unreferenced blobs after tag cleanup.
13+- Reports every protected and unknown candidate with its reason.
1314
14-The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention).
15+The CLI `gordon images prune` runs the same use case and planner as the scheduled job, with the same defaults (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). App state existing on the server is normal and never disables pruning; the plan decides per candidate.
1516
1617 ## Configuration
1718
1819 ```toml
1920 [images]
20-allowed_registries = []
21+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are already allowed.
22+allowed_registries = ["registry.internal:5000"]
2123 require_digest = false
2224
2325 [images.prune]
@@ -30,19 +32,32 @@ keep_last = 3
3032
3133 | Key | Type | Default | Description |
3234 |-----|------|---------|-------------|
33-| `images.allowed_registries` | array | `[]` | Allowlist for external image registries. Empty means no external registries are accepted. Unqualified image names such as `nginx:latest` are treated as Docker Hub (`docker.io`) for policy checks. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |
34-| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |
35+| `images.allowed_registries` | array | `[]` | Additional registry hostname+port entries. Docker Hub (`docker.io`, canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Include non-default ports, e.g. `"registry.example.com:5000"`. Hostnames are case-insensitive; one trailing dot and port `443` are canonicalized. This setting does not configure registry credentials. |
36+| `images.require_digest` | bool | `false` | Require every image reference, including Gordon registry images, to use a valid `@sha256:<64 hex chars>` digest. |
3537 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
3638 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
3739 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
3840
41+The policy validates registry names and strict SHA-256 digest syntax at manifest apply, digest resolution, deployment preflight, and immediately before each pull. It rejects malformed, userinfo-bearing, and ambiguous authorities. External app images currently require digest-pinned references, independently of `require_digest`; external tag resolution is not available. External pulls are anonymous, so adding a host to `allowed_registries` does not enable authenticated private-registry access. This hostname allowlist does **not** prove that DNS resolves to a public address and does not constrain runtime egress; enforce destination-level restrictions in the host firewall or runtime network policy.
42+
3943 ## Retention Behavior
4044
4145 - `latest` is always preserved.
4246 - `keep_last` applies per repository and counts non-`latest` tags.
43-- `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
47+- `keep_last` sets a minimum retained set, not a maximum: a tag named by durable app state, or referenced by the OCI closure of a protected manifest, survives beyond the window.
48+- `keep_last = 0` skips registry tag/blob cleanup entirely (runtime dangling prune still runs).
4449 - Negative `keep_last` values are invalid.
4550
51+## Prune Safety
52+
53+A prune deletes only resources it can positively prove safe. Each candidate gets one verdict: `eligible` (deleted), `protected` (a durable fact claims it), or `unknown` (a required fact could not be read). Protected and unknown candidates are reported and left in place; an operation that deletes nothing is a success.
54+
55+Durable protection covers the DESIRED and ACTIVE state of every app, including stopped and partially converged services; recovery inhibitions; staged and committed apply intents; unfinished operation journal entries; runtime container use for running and stopped containers; recent uploads; and the transitive OCI closure of every retained manifest. Durable digest roots are repository-qualified, so moving a tag away from a deployed image keeps that manifest's config, layers, child manifests, and subject content protected. An unreadable, unsupported, or unresolvable manifest makes the blobs whose safety depended on it `unknown` instead of eligible.
56+
57+Registry content is repository-scoped: a blob is served only to a repository that completed an upload of that digest, and a manifest may only reference config, layers, and child manifests that repository owns. Image labels never authorize deletion of a runtime image; eligibility requires a matching released ownership claim from the app that pinned it, no container use, and a complete inventory.
58+
59+An exclusive GC lease serializes prune against app apply, deploy, start, restart, remove, recovery, and restore. Those paths hold a shared lease from validation/resource acquisition through the durable publication of that resource's protection, and an apply verifies the expected desired revision so a stale apply cannot overwrite a newer one.
60+
4661 ## Related
4762
4863 - [CLI Images Command](../cli/images.md)
M docs/config/index.md +21 -44
@@ -19,12 +19,11 @@ gordon_domain = "gordon.mydomain.com"
1919 [entrypoints.edge]
2020 address = ":443"
2121 protocol = "smart_tcp"
22-
23-[routes]
24-"app.mydomain.com" = "myapp:latest"
2522 ```
2623
27-> **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
24+Application workloads live in standalone app files, not in `gordon.toml` — see [App Manifest](./apps.md). Retired workload keys such as `[routes]`, `[attachments]`, `[network_groups]`, app-like `[[services]]`, `[service_routes]`, `[auto_route]`, and `[previews]` are rejected at startup.
25+
26+> **Note:** `gordon_domain` is the canonical registry and Admin API host.
2827 >
2928 > For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename).
3029
@@ -87,12 +86,8 @@ max_size = 100 # MB before rotation
8786 max_backups = 3 # Old files to keep
8887 max_age = 28 # Days to keep
8988
90-[logging.container_logs]
91-enabled = true
92-dir = "~/.gordon/logs/containers" # Default location
93-max_size = 100
94-max_backups = 3
95-max_age = 28
89+# Workload logs are streamed directly from the container runtime with
90+# `gordon apps logs APP --service SERVICE`.
9691
9792 # Telemetry (OpenTelemetry)
9893 [telemetry]
@@ -104,7 +99,7 @@ metrics = true # Export metrics
10499 logs = true # Bridge zerolog to OTLP logs
105100 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
106101
107-# Environment variables
102+# Installation secret store location
108103 [env]
109104 dir = "~/.gordon/env" # Default location
110105
@@ -114,29 +109,15 @@ auto_create = true # Auto-create from Dockerfile VOLUME
114109 prefix = "gordon" # Volume name prefix
115110 preserve = true # Keep volumes on container removal
116111
117-# Network isolation
112+# Installation network policy (prefix filter for `gordon networks list`)
118113 [network_isolation]
119-enabled = true # Per-app isolated networks
114+enabled = true # Gordon-managed network policy
120115 network_prefix = "gordon" # Network name prefix
121116 internal = false # Set true to block direct egress from isolated networks
122117
123-# Auto-route
124-[auto_route]
125-enabled = false # Auto-create routes from image names
126-
127-# Routes (required)
128-[routes]
129-"app.mydomain.com" = "myapp:latest"
130-"api.mydomain.com" = "myapi:v2.1.0"
131-
132-# Network groups
133-[network_groups]
134-"backend" = ["app.mydomain.com", "api.mydomain.com"]
135-
136-# Attachments
137-[attachments]
138-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
139-"backend" = ["rabbitmq:latest"]
118+# REMOVED in v2.50 (declare apps in standalone files, see ./apps.md):
119+# [routes], [attachments], [network_groups], [[services]]-as-apps,
120+# [service_routes], [auto_route], [previews]
140121
141122 # Backups
142123 [backups]
@@ -152,8 +133,8 @@ monthly = 12
152133
153134 # Images
154135 [images]
155-allowed_registries = [] # Explicit external registries allowed for deploy/attachments
156-require_digest = false # Require digests for allowlisted external registries
136+allowed_registries = [] # Additional exact registry hostname+port entries
137+require_digest = false # Require SHA-256 digests for every image registry
157138
158139 [images.prune]
159140 enabled = false
@@ -161,6 +142,8 @@ schedule = "daily"
161142 keep_last = 3
162143 ```
163144
145+Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry are allowed by default. Add private or other registries with exact hostname+port entries. This hostname policy does not enforce resolved IP destinations or runtime egress; see [Images](./images.md).
146+
164147 ## Configuration Sections
165148
166149 | Section | Description | Documentation |
@@ -171,16 +154,13 @@ keep_last = 3
171154 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
172155 | `[logging]` | Logging configuration | [Logging](./logging.md) |
173156 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
174-| `[env]` | Environment variable settings | [Environment](./env.md) |
157+| `[env]` | Installation secret store location | [Environment](./env.md) |
175158 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
176-| `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
177-| `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
178-| `[routes]` | Domain to image mapping | [Routes](./routes.md) |
159+| `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |
179160 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180161 | `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
181-| `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
182-| `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
183-| `[backups]` | Database backups | [Backups](./backups.md) |
162+| App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) |
163+| `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) |
184164 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
185165 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
186166
@@ -214,13 +194,11 @@ keep_last = 3
214194 | `logging.file.max_size` | `100` |
215195 | `logging.file.max_backups` | `3` |
216196 | `logging.file.max_age` | `28` |
217-| `logging.container_logs.enabled` | `true` |
218197 | `volumes.auto_create` | `true` |
219198 | `volumes.prefix` | `"gordon"` |
220199 | `volumes.preserve` | `true` |
221200 | `network_isolation.enabled` | `true` |
222201 | `network_isolation.internal` | `false` |
223-| `auto_route.enabled` | `false` |
224202 | `backups.enabled` | `false` |
225203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
226204 | `images.allowed_registries` | `[]` |
@@ -236,7 +214,7 @@ keep_last = 3
236214 | `telemetry.logs` | `true` |
237215 | `telemetry.trace_sample_rate` | `1.0` |
238216
239-When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
217+When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is not registered on the TCP listener and `/v2/*` is loopback-only. Local `gordon apps` commands discover the daemon's owner-only admin socket in `$XDG_RUNTIME_DIR/gordon`, `/run/user/<uid>/gordon`, or `~/.gordon/run`; the daemon itself uses the XDG location when configured and otherwise the home fallback. See [Authentication](./auth.md#local-only-mode).
240218
241219 ## Hot Reload
242220
@@ -256,7 +234,6 @@ gordon reload
256234 | `server.max_proxy_response_size` |
257235 | `server.max_concurrent_conns` |
258236
259-> **Note:** Routes are hot-reloaded from the config file. You can still use the API or CLI (`gordon routes add/update/remove`) for live route changes.
260237
261238 ### Requires restart
262239
@@ -286,7 +263,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
286263 ## Related
287264
288265 - [Server Configuration](./server.md)
289-- [Routes Configuration](./routes.md)
266+- [App Manifest](./apps.md)
290267 - [External Routes](./external-routes.md)
291268 - [Standalone Services](./services.md)
292269 - [Traffic Plane](./traffic.md)
M docs/config/logging.md +21 -92
@@ -1,6 +1,6 @@
11 # Logging Configuration
22
3-Configure application and container log collection.
3+Configure Gordon process and HTTP access logging. Workload logs are read directly from the container runtime.
44
55 ## Configuration
66
@@ -16,13 +16,6 @@ max_size = 100
1616 max_backups = 3
1717 max_age = 28
1818
19-[logging.container_logs]
20-enabled = true
21-dir = "~/.gordon/logs/containers"
22-max_size = 100
23-max_backups = 3
24-max_age = 28
25-
2619 [logging.access_log]
2720 enabled = false
2821 format = "json"
@@ -44,24 +37,19 @@ syslog_identifier = "gordon-access"
4437
4538 | Option | Type | Default | Description |
4639 |--------|------|---------|-------------|
47-| `file.enabled` | bool | `false` | Enable file-based logging |
48-| `file.path` | string | - | Path to main log file |
40+| `file.enabled` | bool | `false` | Enable process file logging |
41+| `file.path` | string | `{data_dir}/logs/gordon.log` | Process log path |
4942 | `file.max_size` | int | `100` | Max file size in MB before rotation |
5043 | `file.max_backups` | int | `3` | Number of old files to keep |
5144 | `file.max_age` | int | `28` | Days to keep old files |
5245
53-The Admin API and `gordon logs` read from the process log file. Keep
54-`logging.file.enabled` set to `true` if you need process log streaming.
46+The Admin API and `gordon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming.
5547
56-### Container Logs
48+### Workload Logs
5749
58-| Option | Type | Default | Description |
59-|--------|------|---------|-------------|
60-| `container_logs.enabled` | bool | `true` | Collect container stdout/stderr |
61-| `container_logs.dir` | string | `{data_dir}/logs/containers` | Directory for container logs |
62-| `container_logs.max_size` | int | `100` | Max file size in MB |
63-| `container_logs.max_backups` | int | `3` | Old files to keep |
64-| `container_logs.max_age` | int | `28` | Days to keep files |
50+`gordon apps logs APP --service SERVICE` reads stdout and stderr directly from the container runtime. Gordon does not persist workload logs to files; configure retention in the container runtime's logging driver.
51+
52+The accepted `logging.container_logs` configuration fields are currently not connected to a production log sink and do not create files.
6553
6654 ### Access Log
6755
@@ -89,31 +77,9 @@ Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingesti
8977 | `warn` | Warnings |
9078 | `error` | Errors only |
9179
92-## Log Directory Structure
93-
94-```
95-~/.gordon/logs/
96-├── gordon.log # Main application logs
97-├── gordon.log.1 # Rotated log
98-├── gordon.log.2.gz # Compressed old log
99-└── containers/
100- ├── app_mydomain_com.log # Container logs by domain
101- └── api_mydomain_com.log
102-```
103-
10480 ## Log Rotation
10581
106-Gordon uses automatic log rotation:
107-
108-1. **Size-based**: Rotates when file exceeds `max_size` MB
109-2. **Age-based**: Removes files older than `max_age` days
110-3. **Count-based**: Keeps only `max_backups` old files
111-4. **Compression**: Old files are gzip compressed
112-
113-Example with defaults:
114-- Log grows to 100MB → rotated to `gordon.log.1`
115-- After 3 rotations → oldest is compressed
116-- Files older than 28 days → deleted
82+Process file logs and file-based access logs rotate by size and retain files by count and age. Old files are compressed.
11783
11884 ## Examples
11985
@@ -131,13 +97,6 @@ max_size = 10
13197 max_backups = 2
13298 max_age = 7
13399
134-[logging.container_logs]
135-enabled = true
136-dir = "./logs/containers"
137-max_size = 10
138-max_backups = 2
139-max_age = 7
140-
141100 [logging.access_log]
142101 enabled = true
143102 format = "json"
@@ -160,12 +119,11 @@ max_size = 100
160119 max_backups = 10
161120 max_age = 90
162121
163-[logging.container_logs]
122+[logging.access_log]
164123 enabled = true
165-dir = "~/.gordon/logs/containers"
166-max_size = 100
167-max_backups = 10
168-max_age = 90
124+format = "json"
125+output = "journald"
126+syslog_identifier = "gordon-access"
169127 ```
170128
171129 ### Minimal (Console Only)
@@ -177,60 +135,31 @@ format = "console"
177135
178136 [logging.file]
179137 enabled = false
180-
181-[logging.container_logs]
182-enabled = true
183138 ```
184139
185140 ## Viewing Logs
186141
187-### Gordon Logs
142+### Gordon Process Logs
188143
189144 ```bash
190-# Using gordon logs command
191-gordon logs -f # Follow logs
192-gordon logs -n 100 # Last 100 lines
193-
194-# Direct file access
145+gordon logs -f
146+gordon logs -n 100
195147 tail -f ~/.gordon/logs/gordon.log
196-
197-# With journalctl (if using systemd)
198148 journalctl --user -u gordon -f
199149 ```
200150
201-### Container Logs
151+### Workload Logs
202152
203153 ```bash
204-# View container logs
205-tail -f ~/.gordon/logs/containers/app_mydomain_com.log
206-
207-# All container logs
208-ls ~/.gordon/logs/containers/
154+gordon apps logs blog --service web
155+gordon apps logs blog --service web --follow
209156 ```
210157
211-## Log Format
212-
213-### Console Format
214-
215-```
216-2024-01-15T10:30:00Z INF container deployed domain=app.mydomain.com image=myapp:latest
217-2024-01-15T10:30:01Z INF proxy routing updated route=app.mydomain.com
218-```
219-
220-### JSON Format
221-
222-```json
223-{"level":"info","time":"2024-01-15T10:30:00Z","message":"container deployed","domain":"app.mydomain.com","image":"myapp:latest"}
224-{"level":"info","time":"2024-01-15T10:30:01Z","message":"proxy routing updated","route":"app.mydomain.com"}
225-```
158+These commands stream from the container runtime; they do not read Gordon-managed workload log files.
226159
227160 ## Security
228161
229-Log files are created with secure permissions:
230-- Directories: `0700` (owner only)
231-- Files: `0600` (owner only)
232-
233-Sensitive values (passwords, tokens) are automatically redacted.
162+Process and access log files are created with owner-only permissions. Gordon redacts common credential patterns when serving logs, but application output may still contain sensitive data. Restrict process-log, journal, runtime, and `admin:logs:read` access.
234163
235164 ## Related
236165
D docs/config/network-groups.md +0 -188
@@ -1,188 +0,0 @@
1-# Network Groups
2-
3-Group multiple applications into shared networks for inter-service communication.
4-
5-## Configuration
6-
7-```toml
8-[network_groups]
9-"backend" = ["app.mydomain.com", "api.mydomain.com"]
10-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com"]
11-```
12-
13-## Syntax
14-
15-```toml
16-[network_groups]
17-"<group-name>" = ["<domain>", "<domain>", ...]
18-```
19-
20-| Component | Description |
21-|-----------|-------------|
22-| `group-name` | Name for the shared network |
23-| `domains` | List of routes that share this network |
24-
25-## How It Works
26-
27-Network groups create shared Docker networks for multiple applications:
28-
29-```toml
30-[network_groups]
31-"backend" = ["app.mydomain.com", "api.mydomain.com"]
32-```
33-
34-Creates a shared network: `gordon-backend`
35-
36-```
37-┌────────────────────────────────────────────────┐
38-│ gordon-backend │
39-│ │
40-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
41-│ │ App │←──→│ API │←──→│ Postgres │ │
42-│ │ :3000 │ │ :8080 │ │ :5432 │ │
43-│ └──────────┘ └──────────┘ └──────────┘ │
44-│ │
45-└────────────────────────────────────────────────┘
46-```
47-
48-## Service Discovery
49-
50-Applications in the same group can communicate using container names:
51-
52-```toml
53-[routes]
54-"app.mydomain.com" = "frontend:latest"
55-"api.mydomain.com" = "backend:latest"
56-
57-[network_groups]
58-"services" = ["app.mydomain.com", "api.mydomain.com"]
59-```
60-
61-Server-side code (SSR, API routes) can reach other containers by name:
62-```javascript
63-// In server-side code (SSR / API route) - NOT browser JavaScript
64-const response = await fetch("http://backend:8080/api/data");
65-```
66-
67-Browser clients should use the public route instead:
68-```javascript
69-// In browser JavaScript
70-const response = await fetch("https://api.mydomain.com/api/data");
71-```
72-
73-## Shared Attachments
74-
75-Combine network groups with attachments for shared services:
76-
77-```toml
78-[network_groups]
79-"backend" = ["app.mydomain.com", "api.mydomain.com"]
80-
81-[attachments]
82-"backend" = ["postgres:latest", "redis:latest"]
83-```
84-
85-Both `app.mydomain.com` and `api.mydomain.com` can access:
86-- `postgres:5432`
87-- `redis:6379`
88-
89-## Multiple Groups
90-
91-An application can only be in one network group:
92-
93-```toml
94-# Each app belongs to one group
95-[network_groups]
96-"customer-facing" = ["app.mydomain.com", "api.mydomain.com"]
97-"internal" = ["admin.mydomain.com", "metrics.mydomain.com"]
98-```
99-
100-## Mixed Isolation
101-
102-Combine network groups with per-app attachments:
103-
104-```toml
105-[network_groups]
106-"backend" = ["app.mydomain.com", "api.mydomain.com"]
107-
108-[attachments]
109-# Shared by both apps
110-"backend" = ["redis:latest", "rabbitmq:latest"]
111-
112-# App-specific databases
113-"app.mydomain.com" = ["app-postgres:latest"]
114-"api.mydomain.com" = ["api-postgres:latest"]
115-```
116-
117-Result:
118-- Shared: Redis and RabbitMQ
119-- Isolated: Each app has its own Postgres
120-
121-## Examples
122-
123-### Microservices Architecture
124-
125-```toml
126-[routes]
127-"orders.mydomain.com" = "orders-service:latest"
128-"inventory.mydomain.com" = "inventory-service:latest"
129-"shipping.mydomain.com" = "shipping-service:latest"
130-"payments.mydomain.com" = "payments-service:latest"
131-
132-[network_groups]
133-"order-processing" = ["orders.mydomain.com", "inventory.mydomain.com", "shipping.mydomain.com"]
134-"payment-processing" = ["payments.mydomain.com"]
135-
136-[attachments]
137-"order-processing" = ["rabbitmq:latest"]
138-"payments.mydomain.com" = ["payments-db:latest"]
139-```
140-
141-### Monitoring Stack
142-
143-```toml
144-[routes]
145-"grafana.mydomain.com" = "grafana/grafana:latest"
146-"prometheus.mydomain.com" = "prom/prometheus:latest"
147-"alertmanager.mydomain.com" = "prom/alertmanager:latest"
148-
149-[network_groups]
150-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com", "alertmanager.mydomain.com"]
151-
152-[attachments]
153-"monitoring" = ["prometheus-data:latest"]
154-```
155-
156-### Multi-Tenant SaaS
157-
158-```toml
159-[routes]
160-"app.saas.com" = "saas-app:latest"
161-"api.saas.com" = "saas-api:latest"
162-"admin.saas.com" = "saas-admin:latest"
163-
164-# API and App share backend services
165-[network_groups]
166-"platform" = ["app.saas.com", "api.saas.com"]
167-
168-# Admin is isolated
169-[attachments]
170-"platform" = ["postgres:latest", "redis:latest", "elasticsearch:latest"]
171-"admin.saas.com" = ["admin-db:latest"]
172-```
173-
174-## Network Naming
175-
176-Group networks are named: `{network_prefix}-{group-name}`
177-
178-| Group | Network Name |
179-|-------|--------------|
180-| `backend` | `gordon-backend` |
181-| `monitoring` | `gordon-monitoring` |
182-| `order-processing` | `gordon-order-processing` |
183-
184-## Related
185-
186-- [Network Isolation](./network-isolation.md)
187-- [Attachments](./attachments.md)
188-- [Routes](./routes.md)
M docs/config/network-isolation.md +9 -154
@@ -1,6 +1,6 @@
11 # Network Isolation
22
3-Isolate applications in separate Docker networks for enhanced security.
3+Installation network policy for Gordon-managed networks.
44
55 ## Configuration
66
@@ -11,177 +11,32 @@ network_prefix = "gordon"
1111 internal = false
1212 ```
1313
14-## Migration Note
15-
16-As of this release, `network_isolation.enabled` defaults to `true` (previously `false`).
17-Existing installs that rely on a shared network must explicitly opt out:
18-
19-```toml
20-[network_isolation]
21-enabled = false
22-```
23-
2414 ## Options
2515
2616 | Option | Type | Default | Description |
2717 |--------|------|---------|-------------|
28-| `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
29-| `network_prefix` | string | `"gordon"` | Prefix for created networks |
30-| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. |
31-
32-## How It Works
33-
34-When network isolation is enabled, each application gets its own Docker network:
35-
36-```
37-[network_isolation]
38-enabled = true
39-network_prefix = "gordon"
40-
41-[routes]
42-"app.mydomain.com" = "myapp:latest"
43-"api.mydomain.com" = "myapi:latest"
44-```
45-
46-Creates two isolated networks:
47-- `gordon-app-mydomain-com`
48-- `gordon-api-mydomain-com`
49-
50-## Network Naming
51-
52-Networks are named: `{prefix}-{domain-with-dashes}`
53-
54-| Domain | Network Name |
55-|--------|--------------|
56-| `app.mydomain.com` | `gordon-app-mydomain-com` |
57-| `api.company.io` | `gordon-api-company-io` |
58-| `staging.app.dev` | `gordon-staging-app-dev` |
59-
60-## Security Benefits
61-
62-### Without Network Isolation
63-
64-All containers can potentially communicate:
65-
66-```
67-┌───────────────────────────────────────┐
68-│ Default Bridge Network │
69-│ │
70-│ App A ←──────→ App B ←──────→ App C │
71-│ ↕ ↕ ↕ │
72-│ DB A ←──────→ DB B ←──────→ DB C │
73-│ │
74-└───────────────────────────────────────┘
75-```
76-
77-### With Network Isolation
78-
79-Each app is isolated with its dependencies:
80-
81-```
82-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
83-│ gordon-app-a │ │ gordon-app-b │ │ gordon-app-c │
84-│ │ │ │ │ │
85-│ App A ←→ DB A │ │ App B ←→ DB B │ │ App C ←→ DB C │
86-│ │ │ │ │ │
87-└─────────────────┘ └─────────────────┘ └─────────────────┘
88- ↑ ↑ ↑
89- └───────── No direct communication ─────┘
90-```
91-
92-## Service Discovery
93-
94-Within an isolated network, services are discoverable by name:
95-
96-```toml
97-[network_isolation]
98-enabled = true
99-
100-[attachments]
101-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
102-```
103-
104-Your application connects using simple hostnames:
105-
106-```python
107-# These work within the isolated network
108-db = connect("postgresql://postgres:5432/mydb")
109-cache = connect("redis://redis:6379")
110-```
111-
112-## Examples
113-
114-### Basic Isolation
115-
116-```toml
117-[network_isolation]
118-enabled = true
119-
120-[routes]
121-"app.mydomain.com" = "myapp:latest"
122-"api.mydomain.com" = "myapi:latest"
123-
124-[attachments]
125-"app.mydomain.com" = ["app-postgres:latest"]
126-"api.mydomain.com" = ["api-postgres:latest"]
127-```
128-
129-Each app gets its own network with its own database.
130-
131-### Production Configuration
132-
133-```toml
134-[network_isolation]
135-enabled = true
136-network_prefix = "prod"
137-
138-[routes]
139-"app.company.com" = "company-app:v2.1.0"
140-"api.company.com" = "company-api:v1.5.0"
141-"admin.company.com" = "admin-panel:v1.0.0"
142-```
143-
144-Creates networks:
145-- `prod-app-company-com`
146-- `prod-api-company-com`
147-- `prod-admin-company-com`
148-
149-### Shared Services with Network Groups
150-
151-When apps need to communicate, use network groups:
152-
153-```toml
154-[network_isolation]
155-enabled = true
156-
157-[network_groups]
158-"backend" = ["app.mydomain.com", "api.mydomain.com"]
159-
160-[attachments]
161-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
162-```
18+| `enabled` | bool | `true` | Enable Gordon-managed network policy |
19+| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` |
20+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
16321
164-Both apps share the `gordon-backend` network.
22+Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership.
16523
16624 ## Inspecting Networks
16725
168-View created networks:
26+View Gordon-managed networks:
16927
17028 ```bash
29+gordon networks list
17130 docker network ls | grep gordon
172-# gordon-app-mydomain-com
173-# gordon-api-mydomain-com
174-# gordon-backend
17531 ```
17632
17733 Inspect a network:
17834
17935 ```bash
180-docker network inspect gordon-app-mydomain-com
36+docker network inspect <network-name>
18137 ```
18238
18339 ## Related
18440
185-- [Network Groups](./network-groups.md)
186-- [Attachments](./attachments.md)
41+- [App Manifest](./apps.md)
18742 - [Configuration Overview](./index.md)
D docs/config/preview.md +0 -273
@@ -1,273 +0,0 @@
1-# Preview Environments
2-
3-Push a branch, get a URL. Preview environments give every branch or pull request its own isolated deployment — automatically torn down when it expires.
4-
5-Gordon provisions a preview environment when a matching image tag is pushed to the registry. Each preview gets its own subdomain derived from the base route, lives for a configurable TTL, and is cleaned up automatically when the TTL expires.
6-
7-## Configuration
8-
9-```toml
10-[auto.preview]
11-enabled = true
12-ttl = "48h"
13-separator = "--"
14-tag_patterns = ["preview-*", "pr-*"]
15-data_copy = true
16-env_copy = true
17-```
18-
19-## Options
20-
21-| Option | Type | Default | Description |
22-|--------|------|---------|-------------|
23-| `enabled` | bool | `false` | Enable automatic preview environment creation |
24-| `ttl` | duration string | `"48h"` | How long a preview environment lives before automatic teardown |
25-| `separator` | string | `"--"` | String inserted between the base domain and the branch slug |
26-| `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation |
27-| `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation |
28-| `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container |
29-
30-### `ttl` Format
31-
32-The `ttl` field accepts Go duration strings:
33-
34-| Value | Meaning |
35-|-------|---------|
36-| `"24h"` | 24 hours |
37-| `"48h"` | 48 hours (default) |
38-| `"168h"` | 7 days |
39-| `"0"` | Never expire (manual deletion only) |
40-
41-### `tag_patterns` Matching
42-
43-Patterns use standard glob syntax. A tag must match at least one pattern in the list for a preview environment to be created.
44-
45-| Pattern | Matches |
46-|---------|---------|
47-| `"preview-*"` | `preview-my-feature`, `preview-fix-123` |
48-| `"pr-*"` | `pr-42`, `pr-123` |
49-| `"feat/*"` | `feat/new-api`, `feat/dark-mode` |
50-
51-## Naming Scheme
52-
53-Preview domains are derived from the base route for the deployed image. Gordon combines the base domain, the separator, and a URL-safe slug of the image tag.
54-
55-```text
56-<app><separator><preview-name><rest-of-domain>
57-```
58-
59-For example, with `separator = "--"` and base route `myapp.example.com`:
60-
61-| Image Tag | Preview Domain |
62-|-----------|----------------|
63-| `preview-my-feature` | `myapp--my-feature.example.com` |
64-| `pr-42` | `myapp--42.example.com` |
65-| `preview-fix-login` | `myapp--fix-login.example.com` |
66-
67-The tag prefix matched by `tag_patterns` is stripped to keep domains short. Slashes in tags are replaced with hyphens.
68-
69-## CLI Usage
70-
71-### List Preview Environments
72-
73-```bash
74-gordon preview list
75-```
76-
77-Shows all active preview environments, their domains, TTL remaining, and source route.
78-
79-### Create or Refresh a Preview
80-
81-Previews are created automatically on push. To manually create or reset the TTL of an existing preview:
82-
83-```bash
84-gordon preview create app.example.com --tag preview-my-feature
85-```
86-
87-### Extend a Preview
88-
89-Reset the TTL on an existing preview without redeploying:
90-
91-```bash
92-gordon preview extend myapp--my-feature.example.com
93-gordon preview extend myapp--my-feature.example.com --ttl 24h
94-```
95-
96-Without `--ttl`, the configured default TTL is applied from the current time.
97-
98-### Delete a Preview
99-
100-```bash
101-gordon preview delete myapp--my-feature.example.com
102-```
103-
104-Stops the container, removes the route, and (unless `volumes.preserve = true`) removes any cloned volumes.
105-
106-## CI Usage
107-
108-### GitHub Actions
109-
110-Use tag-based deploys to trigger preview environments automatically.
111-
112-```yaml
113-name: Preview Environment
114-
115-on:
116- pull_request:
117- types: [opened, synchronize]
118-
119-jobs:
120- deploy-preview:
121- runs-on: ubuntu-latest
122- steps:
123- - uses: actions/checkout@v4
124-
125- - name: Log in to Gordon registry
126- run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ vars.GORDON_REGISTRY }} -u deploy --password-stdin
127-
128- - name: Build and push preview image
129- env:
130- TAG: pr-${{ github.event.pull_request.number }}
131- run: |
132- docker build -t ${{ vars.GORDON_REGISTRY }}/myapp:$TAG .
133- docker push ${{ vars.GORDON_REGISTRY }}/myapp:$TAG
134-
135- - name: Comment preview URL
136- uses: actions/github-script@v7
137- with:
138- script: |
139- github.rest.issues.createComment({
140- issue_number: context.issue.number,
141- owner: context.repo.owner,
142- repo: context.repo.repo,
143- body: `Preview deployed: https://app--${{ github.event.pull_request.number }}.example.com`
144- })
145-```
146-
147-Configure Gordon to match the `pr-*` tag convention:
148-
149-```toml
150-[auto.preview]
151-enabled = true
152-ttl = "72h"
153-tag_patterns = ["pr-*"]
154-```
155-
156-### Cleanup on PR Close
157-
158-```yaml
159-name: Teardown Preview
160-
161-on:
162- pull_request:
163- types: [closed]
164-
165-jobs:
166- teardown:
167- runs-on: ubuntu-latest
168- steps:
169- - name: Delete preview environment
170- env:
171- PR_NUMBER: ${{ github.event.pull_request.number }}
172- run: |
173- gordon --server ${{ vars.GORDON_SERVER }} preview delete app--${PR_NUMBER}.example.com
174-```
175-
176-## Lifecycle
177-
178-### Creation
179-
180-When a push matches a `tag_patterns` entry:
181-
182-1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image
183-2. If `data_copy = true`, volumes from the base route are cloned into the preview
184-3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them)
185-4. The preview TTL timer starts
186-5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts)
187-
188-### TTL and Automatic Teardown
189-
190-Gordon checks preview TTLs on a background schedule. When a preview expires:
191-
192-1. In-flight connections are drained
193-2. The container is stopped and removed
194-3. The route is removed from the proxy
195-4. Cloned volumes are removed (unless `volumes.preserve = true`)
196-
197-Use `gordon preview extend` to reset the timer without redeploying.
198-
199-### Volume Cloning
200-
201-When `data_copy = true`, Gordon copies the named volumes attached to the base route into fresh volumes for the preview. This gives the preview a realistic dataset without sharing state with production.
202-
203-- Cloned volumes are prefixed with the preview slug
204-- Cloned volumes are removed on preview teardown unless `volumes.preserve = true`
205-- Set `data_copy = false` to start previews with empty volumes (faster creation, no production data)
206-
207-### Environment Variable Inheritance
208-
209-Preview containers receive environment variables from three sources, merged in this order (last wins):
210-
211-1. **Dockerfile `ENV`** — baked into the image at build time
212-2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`)
213-3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy
214-
215-On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time.
216-
217-#### Overriding variables for a preview
218-
219-To change a variable for a specific preview, set it on the preview domain:
220-
221-```bash
222-gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com
223-```
224-
225-This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones.
226-
227-#### Disabling env inheritance
228-
229-To start previews with a clean environment (Dockerfile `ENV` only):
230-
231-```toml
232-[auto.preview]
233-env_copy = false
234-```
235-
236-## Example Config
237-
238-```toml
239-[server]
240-gordon_domain = "registry.example.com"
241-
242-[auto_route]
243-enabled = true
244-
245-[auto.preview]
246-enabled = true
247-ttl = "48h"
248-separator = "--"
249-tag_patterns = ["preview-*", "pr-*"]
250-data_copy = true
251-env_copy = true
252-
253-[routes]
254-"app.example.com" = "myapp:latest"
255-"api.example.com" = "myapi:latest"
256-
257-[volumes]
258-auto_create = true
259-preserve = false
260-```
261-
262-With this config:
263-- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars
264-- Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`
265-- Both previews expire after 48 hours and volumes are removed on teardown
266-- Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain
267-
268-## Related
269-
270-- [Routes](./routes.md)
271-- [Auto Route](./auto-route.md)
272-- [Volumes](./volumes.md)
273-- [Configuration Overview](./index.md)
M docs/config/reference.md +14 -41
@@ -83,12 +83,9 @@ max_size = 100 # Max size in MB before rotation
8383 max_backups = 3 # Number of old files to keep
8484 max_age = 28 # Days to keep old files
8585
86-[logging.container_logs]
87-enabled = true # Enable container log collection
88-dir = "" # Log directory (default: {data_dir}/container-logs)
89-max_size = 100 # Max size in MB before rotation
90-max_backups = 3 # Number of old files to keep
91-max_age = 28 # Days to keep old files
86+# Workload logs are streamed from the container runtime with
87+# `gordon apps logs APP --service SERVICE`. The accepted
88+# logging.container_logs fields are not connected to a production file sink.
9289
9390 [logging.access_log]
9491 enabled = false # Dedicated HTTP access log for reverse-proxy traffic
@@ -137,18 +134,12 @@ drain_delay = "2s" # Wait after cache invalidation bef
137134 [containers]
138135 security_profile = "compat" # "compat" or "strict"
139136
140-# =============================================================================
141-# AUTO-ROUTE
142-# =============================================================================
143-[auto_route]
144-enabled = false # Create routes from image labels automatically
145-
146137 # =============================================================================
147138 # NETWORK ISOLATION
148139 # =============================================================================
149140 [network_isolation]
150-enabled = true # Enable per-app Docker networks
151-network_prefix = "gordon" # Prefix for created networks
141+enabled = true # Installation network policy for Gordon-managed networks
142+network_prefix = "gordon" # Prefix filter for `gordon networks list`
152143 internal = false # Create Docker internal networks (blocks direct egress)
153144
154145 # =============================================================================
@@ -159,13 +150,9 @@ auto_create = true # Auto-create volumes from Dockerfi
159150 prefix = "gordon" # Volume name prefix
160151 preserve = true # Keep volumes when containers are removed
161152
162-# =============================================================================
163-# ROUTES
164-# =============================================================================
165-[routes]
166-# "domain.com" = { image = "image:tag" }
167-# "insecure.domain.com" = { image = "image:tag", https = false }
168-# Legacy "http://domain.com" keys are read for compatibility and rewritten on save.
153+# REMOVED in v2.50: [routes], [attachments], [network_groups],
154+# [[services]]-as-apps, [service_routes], [auto_route], [previews].
155+# Declare apps in standalone files (see ./apps.md).
169156
170157 # =============================================================================
171158 # EXTERNAL ROUTES
@@ -195,18 +182,6 @@ preserve = true # Keep volumes when containers are
195182 # publish = "127.0.0.1:38016"
196183 # trusted_cidrs = ["100.64.0.0/10"]
197184
198-# =============================================================================
199-# NETWORK GROUPS
200-# =============================================================================
201-[network_groups]
202-# "group-name" = ["domain1.com", "domain2.com"]
203-
204-# =============================================================================
205-# ATTACHMENTS
206-# =============================================================================
207-[attachments]
208-# "domain-or-group" = ["image1:tag", "image2:tag"]
209-
210185 # =============================================================================
211186 # BACKUPS
212187 # =============================================================================
@@ -225,6 +200,8 @@ monthly = 0 # Keep N monthly backups per DB
225200 # IMAGES
226201 # =============================================================================
227202 [images]
203+# Defaults: docker.io/registry-1.docker.io, ghcr.io, quay.io, Gordon registry.
204+# Add private or other registries as exact hostname+port entries.
228205 allowed_registries = []
229206 require_digest = false
230207
@@ -265,7 +242,7 @@ keep_last = 3 # Keep N newest tags per repository
265242 | `tls.acme.email` | `""` | ACME account email when enabled |
266243 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
267244 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |
268-| `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
245+| `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, TCP `/admin/*` not registered, owner-only admin socket for local `gordon apps`) |
269246 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
270247 | `auth.token_expiry` | `"30d"` | 30 days |
271248 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
@@ -280,10 +257,6 @@ keep_last = 3 # Keep N newest tags per repository
280257 | `logging.file.max_size` | `100` | 100 MB |
281258 | `logging.file.max_backups` | `3` | Keep 3 old files |
282259 | `logging.file.max_age` | `28` | 28 days |
283-| `logging.container_logs.enabled` | `true` | Container logs enabled |
284-| `logging.container_logs.max_size` | `100` | 100 MB |
285-| `logging.container_logs.max_backups` | `3` | Keep 3 old files |
286-| `logging.container_logs.max_age` | `28` | 28 days |
287260 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
288261 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
289262 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
@@ -307,7 +280,6 @@ keep_last = 3 # Keep N newest tags per repository
307280 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
308281 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
309282 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
310-| `auto_route.enabled` | `false` | Auto-route disabled |
311283 | `network_isolation.enabled` | `true` | Network isolation enabled |
312284 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
313285 | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
@@ -342,8 +314,8 @@ keep_last = 3 # Keep N newest tags per repository
342314 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
343315 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
344316 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
345-| `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected |
346-| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |
317+| `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. |
318+| `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
347319 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
348320 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
349321 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
@@ -419,5 +391,6 @@ gordon serve
419391 - [Telemetry](./telemetry.md)
420392 - [Network Isolation](./network-isolation.md)
421393 - [Volumes](./volumes.md)
394+- [App Manifest](./apps.md)
422395 - [Standalone Services](./services.md)
423396 - [Images](./images.md)
D docs/config/routes.md +0 -206
@@ -1,206 +0,0 @@
1-# Routes Configuration
2-
3-Routes map hostnames to container images.
4-
5-## Configuration
6-
7-```toml
8-[routes]
9-"app.mydomain.com" = { image = "myapp:latest" }
10-"api.mydomain.com" = { image = "myapi:v2.1.0" }
11-"admin.mydomain.com" = { image = "admin-panel:v1.0.0" }
12-```
13-
14-## Syntax
15-
16-```toml
17-[routes]
18-"<domain>" = { image = "<image>:<tag>" }
19-```
20-
21-| Component | Description |
22-|-----------|-------------|
23-| `domain` | Public, fully qualified domain name |
24-| `image` | Full container image reference, including tag |
25-| `https` | Optional; add `false` for HTTP-only routes |
26-
27-Legacy `http://...` route keys are still read for backward compatibility and rewritten on the next save.
28-
29-## Route Types
30-
31-### HTTPS Routes (Default)
32-
33-Standard routes expect HTTPS traffic (terminated by Cloudflare or similar):
34-
35-```toml
36-[routes]
37-"app.mydomain.com" = { image = "myapp:latest" }
38-```
39-
40-Route domains must be plain hostnames. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals.
41-
42-### Development Routes
43-
44-For local testing, use a hostname you can resolve yourself:
45-
46-```toml
47-[routes]
48-"dev-app.example.com" = { image = "internal-app:latest", https = false }
49-```
50-
51-## Version Strategies
52-
53-### Latest Tag
54-
55-Always deploy the most recent push:
56-
57-```toml
58-[routes]
59-"app.mydomain.com" = { image = "myapp:latest" }
60-```
61-
62-### Pinned Version
63-
64-Deploy a specific version:
65-
66-```toml
67-[routes]
68-"app.mydomain.com" = { image = "myapp:v2.1.0" }
69-```
70-
71-Update the config to deploy a new version:
72-
73-```toml
74-[routes]
75-"app.mydomain.com" = { image = "myapp:v2.2.0" } # Changed
76-```
77-
78-### Semantic Versioning
79-
80-Use different routes for different versions:
81-
82-```toml
83-[routes]
84-"app.mydomain.com" = { image = "myapp:v2.1.0" } # Production
85-"staging.mydomain.com" = { image = "myapp:staging" } # Staging
86-"canary.mydomain.com" = { image = "myapp:canary" } # Canary
87-```
88-
89-## Multiple Routes
90-
91-### Same Image, Different Domains
92-
93-```toml
94-[routes]
95-"app.mydomain.com" = { image = "myapp:latest" }
96-"www.mydomain.com" = { image = "myapp:latest" }
97-"mydomain.com" = { image = "myapp:latest" }
98-```
99-
100-### Multiple Services
101-
102-```toml
103-[routes]
104-"app.mydomain.com" = { image = "frontend:latest" }
105-"api.mydomain.com" = { image = "backend:v2.1.0" }
106-"docs.mydomain.com" = { image = "documentation:latest" }
107-"status.mydomain.com" = { image = "status-page:v1.0.0" }
108-```
109-
110-## How Routing Works
111-
112-1. Request arrives for `app.mydomain.com`
113-2. Gordon looks up the route in configuration
114-3. Finds running container for `myapp:latest`
115-4. Proxies request to container's exposed port
116-5. Returns response to client
117-
118-```
119-Client ─> Gordon Proxy ─> Container
120- (port 80) (exposed port)
121-```
122-
123-## Deployment Flow
124-
125-When you push an image:
126-
127-```bash
128-docker push registry.mydomain.com/myapp:latest
129-```
130-
131-1. Gordon receives the image
132-2. Checks routes for any that use `myapp:latest`
133-3. Deploys new container for each matching route
134-4. Updates proxy to route traffic to new container
135-5. Stops old container
136-
137-## Route Changes
138-
139-Route changes in the config file are hot-reloaded automatically:
140-
141-1. Edit `[routes]` in `gordon.toml`
142-2. Save the file
143-3. Gordon reloads the updated routes and proxy config
144-
145-Legacy `http://...` route keys are still read here for backward compatibility and rewritten the next time Gordon saves the config.
146-
147-You can still manage routes with the API or CLI if you prefer live mutations:
148-
149-```bash
150-gordon routes add newapp.mydomain.com newapp:latest
151-gordon routes add app.mydomain.com myapp:v2.2.0
152-gordon routes remove oldapp.mydomain.com
153-```
154-
155-## Examples
156-
157-### Development Setup
158-
159-```toml
160-[routes]
161-"dev-app.example.com" = { image = "myapp:latest" }
162-"dev-api.example.com" = { image = "myapi:latest" }
163-```
164-
165-Add to `/etc/hosts`:
166-
167-```text
168-127.0.0.1 dev-app.example.com dev-api.example.com registry.example.com
169-```
170-
171-### Production Setup
172-
173-```toml
174-[routes]
175-"app.company.com" = { image = "company-app:v2.1.0" }
176-"api.company.com" = { image = "company-api:v1.5.2" }
177-"admin.company.com" = { image = "admin-panel:v1.0.1" }
178-"docs.company.com" = { image = "company-docs:latest" }
179-```
180-
181-### Multi-Tenant SaaS
182-
183-```toml
184-[routes]
185-# Platform services
186-"app.saas-platform.com" = { image = "saas-frontend:v2.1.0" }
187-"api.saas-platform.com" = { image = "saas-api:v3.2.1" }
188-
189-# Customer subdomains
190-"acme.saas-platform.com" = { image = "saas-app:v2.1.0" }
191-"beta.saas-platform.com" = { image = "saas-app:v2.1.0" }
192-
193-# Customer custom domains
194-"portal.acme-corp.com" = { image = "saas-app:v2.1.0" }
195-```
196-
197-## External Services
198-
199-For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md).
200-
201-## Related
202-
203-- [Configuration Overview](./index.md)
204-- [Auto Route](./auto-route.md)
205-- [Attachments](./attachments.md)
206-- [External Routes](./external-routes.md)
M docs/config/secrets.md +17 -43
@@ -1,6 +1,6 @@
11 # Secrets Configuration
22
3-Configure how Gordon stores and retrieves sensitive data.
3+Configure how Gordon stores and retrieves sensitive data. Two separate stores exist: installation secrets (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`).
44
55 ## Configuration
66
@@ -52,11 +52,9 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
5252 - Standard Unix tooling
5353 - Works with team GPG keys
5454
55-**Route secrets storage:**
56-- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
57-- Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
58-- Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest
59-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
55+**Installation secrets storage:**
56+- `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
57+- Eligible plaintext `.env` files are imported into pass at startup and removed only after all entries are stored successfully; conflicts or failures leave the source file for operator review
6058
6159 ### SOPS
6260
@@ -79,7 +77,6 @@ sops secrets.yaml
7977
8078 **Usage in env files:**
8179 ```bash
82-# ~/.gordon/env/app_mydomain_com.env
8380 API_SECRET=${sops:secrets.yaml:api.secret}
8481 DB_PASSWORD=${sops:secrets.yaml:database.password}
8582 ```
@@ -94,15 +91,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
9491 - Path traversal (`..`) is blocked
9592 - Only relative paths from your config directory are allowed
9693
97-**Route secrets storage:**
98-- Domain secrets stay in `.env` files
99-- Use `${sops:...}` syntax to resolve encrypted values
100-
101-**Attachment secrets storage:**
102-- Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files
103-- Use `${sops:...}` syntax inside attachment env files to resolve encrypted values
104-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
105-
10694 ### Unsafe (Development Only)
10795
10896 Stores secrets as plain text files:
@@ -120,11 +108,6 @@ secrets_backend = "unsafe"
120108 │ └── token_secret
121109 ```
122110
123-**Attachment secrets:**
124-- Stored as `gordon-<container-name>.env` files in the env directory
125-- Example: `gordon-app__mydomain__com-postgres.env`
126-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
127-
128111 **Usage:**
129112 ```bash
130113 # Create secret
@@ -151,9 +134,19 @@ API_KEY=${pass:myapp/api-key}
151134 ```bash
152135 # ${sops:<file>:<key.path>}
153136 DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
154-API_SECRET=${sops:production.yaml:api.secret.key}
137+API_SECRET=${sops:production.yaml:api.key}
155138 ```
156139
140+## App Secret Values
141+
142+App secret names are registered in the app manifest (`[service.secrets]` maps ENV name to secret name); values are written separately and stay in pass:
143+
144+```bash
145+gordon apps secrets set blog --service web DATABASE_URL=...
146+```
147+
148+Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).
149+
157150 ## Examples
158151
159152 ### Production with Pass
@@ -182,29 +175,10 @@ enabled = false
182175 secrets_backend = "unsafe"
183176 ```
184177
185-### Enterprise with SOPS
186-
187-```toml
188-[auth]
189-enabled = true
190-secrets_backend = "sops"
191-token_secret = "gordon/auth/token_secret"
192-```
193-
194-Environment file:
195-```bash
196-# ~/.gordon/env/app_company_com.env
197-NODE_ENV=production
198-DATABASE_URL=postgresql://db:5432/app
199-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
200-API_KEY=${sops:secrets.yaml:api.key}
201-JWT_SECRET=${sops:secrets.yaml:jwt.secret}
202-```
203-
204178 ## Security Recommendations
205179
206180 1. **Production**: Always use `pass` or `sops` backend
207-2. **Never commit**: Don't commit unencrypted secrets to git
181+2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values)
208182 3. **Rotate regularly**: Regenerate tokens and passwords periodically
209183 4. **Least privilege**: Use separate secrets per environment
210184 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
@@ -212,5 +186,5 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret}
212186 ## Related
213187
214188 - [Authentication](./auth.md)
215-- [Environment Variables](./env.md)
189+- [App Manifest](./apps.md)
216190 - [Configuration Overview](./index.md)
M docs/config/security-hardening.md +24 -12
@@ -24,37 +24,40 @@ Container and deploy logs can include environment-derived output. Gordon gates l
2424 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
2525 ```
2626
27-- `/admin/logs` and deploy failure logs require `admin:logs:read`.
27+- `/admin/logs`, app failure diagnostics, and deploy failure logs require `admin:logs:read`.
2828 - `admin:status:read` does not grant log access.
29-- Common secret patterns are redacted before logs are returned.
29+- Operation errors returned to `admin:apps:read` callers are stable and log-free. Application diagnostics are a separate field, redacted of the affected service's resolved secret values before storage, and returned only to callers holding `admin:logs:read`. If a declared secret cannot be resolved for redaction, Gordon drops the diagnostics instead of storing unredacted output. Mutation responses never include application output.
30+- Process and container log sources remain operator-owned data. Gordon redacts common credential patterns when serving logs through its API, but this is defense in depth rather than proof that arbitrary application output is secret-free. Restrict filesystem, journal, runtime, backup, and `admin:logs:read` access accordingly.
3031
3132 ## Volume pruning scope
3233
33-Volume pruning only removes unused Docker volumes explicitly managed by Gordon (`gordon.managed=true`). It ignores unrelated Docker volumes even if they are unused.
34+Volume pruning removes a volume only when Gordon has a durable `released` ownership record, the runtime app/incarnation/service labels agree with that record, and no container mounts it. A `gordon.managed=true` label by itself is not deletion authority; retained, unknown, contradictory, and unrelated volumes survive.
35+
36+Use `gordon volumes prune --dry-run` before deletion. Do not substitute `docker volume prune` or an equivalent runtime command: runtime-native pruning bypasses Gordon's ownership and retention checks.
3437
3538 Use dedicated admin scopes:
3639
3740 - `admin:volumes:read` for listing volumes.
3841 - `admin:volumes:write` for prune operations.
3942
40-## Pass migration plaintext handling
43+## Pass import plaintext handling
4144
42-When Gordon migrates legacy plaintext `.env` files into `pass`, it removes the plaintext source after a successful migration and does not leave `.env.migrated` copies by default. If pass entries already exist, migration fails closed and leaves the plaintext file in place for manual operator review rather than deleting potentially unique values.
45+With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review.
4346
4447 ## External image registries
4548
46-Gordon's configured registry is always allowed. Explicit external registries must be allowlisted:
49+Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
4750
4851 ```toml
4952 [images]
50-allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"]
53+allowed_registries = ["registry.internal:5000"]
5154 require_digest = true
5255 ```
5356
54-- Empty `allowed_registries` rejects explicit external registries.
55-- `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected.
56-- `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.
57-- Include ports in allowlist entries when the registry uses a non-default port.
57+- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
58+- Other registries are accepted only when their exact hostname+port is configured. Allowlisting does not configure credentials; external resolution and pulls are anonymous unless the runtime already has suitable access.
59+- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
60+- The allowlist restricts hostnames, not resolved IPs. It cannot prove a DNS hostname is non-private and does not replace firewall or runtime egress controls.
5861
5962 ## Smart TCP Raw Fallback
6063
@@ -95,7 +98,16 @@ enabled = true
9598 internal = true
9699 ```
97100
98-`internal = false` remains the compatibility default because some applications and attachments need direct egress during startup.
101+`internal = false` remains the compatibility default because some applications need direct egress during startup.
102+
103+Every app container joins an incarnation-owned private network derived from the app's internal UUID; shared memberships come only from explicit `[[network.shared]]` declarations, and memory/CPU/PID limits apply on every create and recovery path.
104+
105+## Registry exposure
106+
107+- With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
108+- With `auth.enabled = false`, the registry is local-only: the public proxy refuses registry-domain requests, and the registry handler accepts only direct loopback connections carrying the instance credentials.
109+- Registry requests are parsed once into a validated operation used by both authorization and dispatch, so the repository a token is checked against is exactly the repository served.
110+- Blob and upload access is repository-scoped: an upload UUID is usable only by the repository that started it, and a blob is served only to a repository that completed an upload of that digest.
99111
100112 ## Container runtime profile
101113
M docs/config/server.md +8 -11
@@ -102,7 +102,7 @@ Challenge behavior:
102102
103103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
104104
105-Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to configured HTTPS routes. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
105+Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to app HTTPS hosts. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
106106
107107 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
108108
@@ -116,7 +116,7 @@ DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS vis
116116
117117 The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.
118118
119-Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
119+Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by app HTTPS hosts. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
120120
121121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
122122
@@ -128,7 +128,7 @@ When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (
128128
129129 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.
130130
131-On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
131+On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts are served without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
132132
133133 #### HTTP to HTTPS redirect
134134
@@ -187,7 +187,7 @@ gordon_domain = "gordon.mydomain.com"
187187 This domain is used for:
188188 - Docker login and image push/pull operations
189189 - Admin API access (`/admin/*` endpoints)
190-- CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
190+- CLI remote targeting (`gordon apps --remote https://gordon.mydomain.com`)
191191 - Authentication endpoints (`/auth/*`)
192192
193193 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
@@ -220,13 +220,10 @@ data_dir = "~/.gordon" # Default for user installations
220220 ├── registry/ # Container images and manifests
221221 │ ├── blobs/
222222 │ └── manifests/
223-├── env/ # Environment files per domain
224-│ ├── app_mydomain_com.env
225-│ └── api_mydomain_com.env
226-├── logs/ # Application and container logs
223+├── env/ # Installation secret-store data
224+├── logs/ # Gordon process and file-based access logs
227225 │ ├── gordon.log
228-│ ├── proxy.log
229-│ └── containers/
226+│ └── access.log
230227 └── secrets/ # Secrets (unsafe backend only)
231228 ```
232229
@@ -430,6 +427,6 @@ sudo firewall-cmd --reload
430427
431428 - [Configuration Overview](./index.md)
432429 - [Installation Guide](../installation.md)
433-- [Routes Configuration](./routes.md)
430+- [App Manifest](./apps.md)
434431 - [Standalone Services](./services.md)
435432 - [Traffic Plane Configuration](./traffic.md)
M docs/config/traffic.md +5 -5
@@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive packets
44
55 ## Smart TCP Edge Entrypoint
66
7-Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, normal Gordon routes use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
7+Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, app HTTP hosts use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
88
99 ```toml
1010 [entrypoints.edge]
@@ -15,7 +15,7 @@ trusted_cidrs = []
1515
1616 Do not treat `entrypoints.edge.address` as an HTTP port or an HTTPS port. It is one TCP socket that sniffs each new connection and dispatches the original byte stream.
1717
18-Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve normal Gordon routes through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
18+Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve app HTTP hosts through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
1919
2020 ## Smart TCP Dispatch Order
2121
@@ -73,7 +73,7 @@ protocol = "tcp"
7373
7474 Service references use:
7575
76-- `route:<domain>` for configured HTTP routes
76+- `route:<domain>` for app HTTP hosts from ACTIVE state (merged with installation external routes)
7777 - `external_route:<domain>` for configured external HTTP routes
7878 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends
7979 - `service:<service>:<port-name>` for Gordon-managed standalone service backends
@@ -96,7 +96,7 @@ sni = "raw.example.com"
9696 service = "network_service:raw:tls"
9797 ```
9898
99-Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. HTTPS application routes that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
99+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. App HTTPS hosts that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
100100
101101 ## Raw TCP Fallback
102102
@@ -176,6 +176,6 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
176176 ## Related
177177
178178 - [Server Settings](./server.md)
179-- [Routes](./routes.md)
179+- [App Manifest](./apps.md)
180180 - [Standalone Services](./services.md)
181181 - [CLI traffic status](../cli/traffic.md)
M docs/config/volumes.md +29 -137
@@ -1,165 +1,57 @@
11 # Volumes Configuration
22
3-Configure automatic persistent storage for containers.
3+Persistent app storage is declared in each app manifest. The installation-level `[volumes]` settings apply to non-app volume management and do not control declarative app storage.
44
5-## Configuration
5+## Declarative app volumes
66
7-```toml
8-[volumes]
9-auto_create = true
10-prefix = "gordon"
11-preserve = true
12-```
13-
14-## Options
15-
16-| Option | Type | Default | Description |
17-|--------|------|---------|-------------|
18-| `auto_create` | bool | `true` | Automatically create volumes from Dockerfile VOLUME |
19-| `prefix` | string | `"gordon"` | Prefix for volume names |
20-| `preserve` | bool | `true` | Keep volumes when containers are removed |
21-
22-## How It Works
23-
24-Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives:
25-
26-```dockerfile
27-FROM postgres:18
28-VOLUME ["/var/lib/postgresql/data"]
29-```
30-
31-When Gordon deploys this container:
32-
33-1. Reads `VOLUME` directives from image metadata
34-2. Creates named volumes with `prefix-domain-path` naming
35-3. Mounts volumes to the container
36-4. Preserves data across container updates
37-
38-## Volume Naming
39-
40-Volumes are named: `{prefix}-{domain}-{path}`
41-
42-| Domain | Volume Path | Volume Name |
43-|--------|-------------|-------------|
44-| `app.mydomain.com` | `/data` | `gordon-app-mydomain-com-data` |
45-| `db.mydomain.com` | `/var/lib/postgresql/data` | `gordon-db-mydomain-com-var-lib-postgresql-data` |
46-
47-## Persistence
48-
49-### Default: Preserve Volumes
50-
51-```toml
52-[volumes]
53-preserve = true
54-```
55-
56-With `preserve = true`:
57-- Volumes persist when containers are updated
58-- Data survives container restarts
59-- Volumes remain even if container is removed
60-
61-### Remove with Container
62-
63-```toml
64-[volumes]
65-preserve = false
66-```
67-
68-With `preserve = false`:
69-- Volumes are removed when containers are removed
70-- Useful for stateless containers
71-- Frees up disk space automatically
72-
73-## Examples
74-
75-### Database Container
76-
77-```dockerfile
78-# my-postgres.Dockerfile
79-FROM postgres:18
80-VOLUME ["/var/lib/postgresql/data"]
81-ENV POSTGRES_DB=myapp
82-ENV POSTGRES_USER=app
83-```
84-
85-Gordon automatically:
86-- Creates `gordon-db-mydomain-com-var-lib-postgresql-data` volume
87-- Mounts it to `/var/lib/postgresql/data`
88-- Preserves data across postgres container updates
89-
90-### Application with Uploads
91-
92-```dockerfile
93-FROM node:18
94-WORKDIR /app
95-VOLUME ["/app/uploads", "/app/data"]
96-COPY . .
97-CMD ["npm", "start"]
98-```
99-
100-Creates two volumes:
101-- `gordon-app-mydomain-com-app-uploads`
102-- `gordon-app-mydomain-com-app-data`
103-
104-### Custom Prefix
7+Services declare persistent mounts in the app manifest:
1058
1069 ```toml
107-[volumes]
108-prefix = "prod"
10+[[service.volume]]
11+name = "database-data"
12+path = "/var/lib/postgresql/data"
10913 ```
11014
111-Volume names become:
112-- `prod-app-mydomain-com-data`
113-- `prod-db-mydomain-com-var-lib-postgresql-data`
15+A volume name is unique within its service. Gordon creates an incarnation-owned runtime volume, records the app, app UUID, service, and logical volume ownership, and reuses it across replacement and restart. App manifests do not support bind mounts or sharing one volume between services.
11416
115-## Managing Volumes
17+Every Dockerfile `VOLUME` path must have a matching `[[service.volume]]` declaration. Deployment fails closed when an image declares an unmanaged volume.
11618
117-### List Volumes
19+Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records.
11820
119-```bash
120-docker volume ls | grep gordon
121-```
21+## Retention
12222
123-### Inspect Volume
23+Ordinary app operations retain data:
12424
125-```bash
126-docker volume inspect gordon-app-mydomain-com-data
127-```
25+- deploy and restart reuse the service's volumes;
26+- removing a service retains its volumes;
27+- `gordon apps remove` retains volumes under the removed app's internal UUID;
28+- a new app that reuses the public name does not adopt retained volumes.
12829
129-### Backup Volume
30+Gordon does not automatically delete retained app volumes. Back up persistent data before any manual deletion, and use database-native backup and restore procedures for databases.
13031
131-```bash
132-docker run --rm \
133- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \
134- -v $(pwd):/backup \
135- alpine tar -czf /backup/db-backup.tar.gz -C /data .
136-```
32+## Pruning
13733
138-### Restore Volume
34+Use Gordon's ownership-aware command:
13935
14036 ```bash
141-docker run --rm \
142- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \
143- -v $(pwd):/backup \
144- alpine tar -xzf /backup/db-backup.tar.gz -C /data
37+gordon volumes prune --dry-run
38+gordon volumes prune
14539 ```
14640
147-## Volume Cleanup
41+A volume is eligible only when all of these requirements hold:
14842
149-If you have orphaned volumes:
43+1. Gordon has a durable ownership record marking it `released`.
44+2. Runtime labels agree with the record's app, app incarnation UUID, and service.
45+3. No container mounts the volume.
15046
151-```bash
152-# List all gordon volumes
153-docker volume ls -f name=gordon
47+Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions.
15448
155-# Remove specific volume (warning: deletes data!)
156-docker volume rm gordon-old-app-data
49+> **Warning:** Do not use `docker volume prune`, `podman volume prune`, or equivalent runtime cleanup for Gordon data. Those commands bypass Gordon's ownership and retention checks and can delete an unmounted retained volume.
15750
158-# Prune unused volumes (be careful!)
159-docker volume prune
160-```
51+See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output.
16152
16253 ## Related
16354
164-- [Attachments](./attachments.md)
55+- [App Manifest](./apps.md)
56+- [Volumes CLI](../cli/volumes.md)
16557 - [Configuration Overview](./index.md)
M docs/deployment/generic-ci.md +16 -16
@@ -5,7 +5,7 @@ Deploy with Gordon from any CI/CD system.
55 ## Requirements
66
77 - Docker available on CI runner (for building images)
8-- Network access to your Gordon server (HTTPS)
8+- Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`
99 - Gordon binary (optional but recommended)
1010
1111 ## Recommended: gordon push
@@ -17,7 +17,7 @@ On your Gordon server:
1717 ```bash
1818 gordon auth token generate \
1919 --subject ci-deploy \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \
20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2121 --expiry 0
2222 ```
2323
@@ -42,7 +42,7 @@ chmod +x /usr/local/bin/gordon
4242 export GORDON_TOKEN="$GORDON_TOKEN"
4343 gordon push --build \
4444 --remote "$GORDON_REMOTE" \
45- --no-confirm
45+
4646 ```
4747
4848 Gordon auto-detects the version from CI environment variables:
@@ -69,7 +69,7 @@ docker push gordon.example.com/myapp:v1.0.0
6969 ```
7070
7171 This requires the token subject (`ci-deploy`) as the username.
72-Gordon auto-deploys when it receives the image.
72+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
7373
7474 ## CI System Examples
7575
@@ -89,7 +89,7 @@ pipeline {
8989 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
9090 -o /usr/local/bin/gordon
9191 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE" --no-confirm
92+ gordon push --build --remote "$GORDON_REMOTE"
9393 '''
9494 }
9595 }
@@ -120,8 +120,7 @@ jobs:
120120 name: Deploy
121121 command: |
122122 gordon push --build \
123- --remote "$GORDON_REMOTE" \
124- --no-confirm
123+ --remote "$GORDON_REMOTE"
125124
126125 workflows:
127126 deploy:
@@ -153,7 +152,7 @@ steps:
153152 - apk add --no-cache curl
154153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155154 - chmod +x /usr/local/bin/gordon
156- - gordon push --build --remote "$GORDON_REMOTE" --no-confirm
155+ - gordon push --build --remote "$GORDON_REMOTE"
157156
158157 trigger:
159158 event:
@@ -162,11 +161,13 @@ trigger:
162161
163162 ## Token Scopes Reference
164163
164+Use the minimum scopes for the steps the pipeline performs. A repository restriction limits registry access only; it does not narrow `admin:*` permissions.
165+
165166 | Workflow | Required Scopes |
166167 |----------|----------------|
167-| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |
168-| Push only (no deploy) | `push,pull,admin:routes:read` |
169-| docker push (auto-deploy) | `push,pull` |
168+| Build + push, then apply + deploy | `push,pull,admin:apps:read,admin:apps:write` |
169+| Push only (no deploy) | `push,pull` |
170+| docker push, then apply + deploy | `push,pull` + `admin:apps:read,admin:apps:write` for the CLI step |
170171
171172 ## Troubleshooting
172173
@@ -174,14 +175,13 @@ trigger:
174175
175176 The token is invalid or has been revoked. Generate a new one.
176177
177-### "no route configured for image"
178+### "deploy target not found" / app has no desired state
178179
179-The route must exist before pushing. Create it with:
180+Push only stores the image. Declare the app first:
180181
181182 ```bash
182-gordon routes add myapp.example.com myapp
183-# or for first deploy:
184-gordon bootstrap myapp.example.com myapp
183+gordon apps apply --file myapp.toml --remote "$GORDON_REMOTE"
184+gordon apps deploy myapp --remote "$GORDON_REMOTE"
185185 ```
186186
187187 ### Version shows "latest"
M docs/deployment/github-actions.md +10 -14
@@ -4,8 +4,8 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme
44
55 ## Prerequisites
66
7-1. Gordon server running with registry authentication enabled
8-2. Deployment token generated with the required scopes
7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8+2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps
99 3. GitHub repository secrets configured
1010
1111 ## Recommended: gordon push
@@ -19,7 +19,7 @@ On your Gordon server:
1919 ```bash
2020 gordon auth token generate \
2121 --subject github-actions \
22- --scopes "push,pull,admin:routes:read,admin:config:write" \
22+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2323 --expiry 0
2424 ```
2525
@@ -72,8 +72,7 @@ jobs:
7272 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
7373 run: |
7474 gordon push --build \
75- --remote ${{ secrets.GORDON_REMOTE }} \
76- --no-confirm
75+ --remote ${{ secrets.GORDON_REMOTE }}
7776 ```
7877
7978 #### Continuous Deploy on Main
@@ -105,8 +104,7 @@ jobs:
105104 run: |
106105 gordon push --build \
107106 --remote ${{ secrets.GORDON_REMOTE }} \
108- --tag latest \
109- --no-confirm
107+ --tag latest
110108 ```
111109
112110 #### Manual Dispatch
@@ -141,7 +139,7 @@ jobs:
141139 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142140 DEPLOY_TAG: ${{ inputs.tag }}
143141 run: |
144- args=(push --build --remote "$GORDON_REMOTE" --no-confirm)
142+ args=(push --build --remote "$GORDON_REMOTE")
145143 if [[ -n "$DEPLOY_TAG" ]]; then
146144 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
147145 args+=(--tag "$DEPLOY_TAG")
@@ -176,8 +174,7 @@ jobs:
176174 gordon push --build \
177175 --remote ${{ secrets.GORDON_REMOTE }} \
178176 --file ./services/api/Dockerfile \
179- myapp-api \
180- --no-confirm
177+ myapp-api
181178
182179 - name: Deploy Web
183180 env:
@@ -186,8 +183,7 @@ jobs:
186183 gordon push --build \
187184 --remote ${{ secrets.GORDON_REMOTE }} \
188185 --file ./services/web/Dockerfile \
189- myapp-web \
190- --no-confirm
186+ myapp-web
191187 ```
192188
193189 #### With Build Args
@@ -204,7 +200,7 @@ Pass build arguments to the Docker build:
204200 --build-arg NODE_ENV=production \
205201 --build-arg API_URL=https://api.example.com \
206202 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
207- --no-confirm
203+
208204 ```
209205
210206 ### setup-gordon Action Reference
@@ -220,7 +216,7 @@ Pass build arguments to the Docker build:
220216
221217 ## Alternative: Docker-based Workflow
222218
223-For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed.
219+For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Pushing only stores the image — deploy explicitly with `gordon apps deploy` afterwards.
224220
225221 ### Setup
226222
M docs/deployment/gitlab-ci.md +9 -9
@@ -4,8 +4,8 @@ Automated deployment with GitLab CI/CD using Gordon's push command.
44
55 ## Prerequisites
66
7-1. Gordon server running with registry authentication enabled
8-2. Generated deployment token
7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8+2. A least-privilege token: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the pipeline applies or deploys apps
99 3. GitLab CI/CD variables configured
1010
1111 ## Quick Setup
@@ -17,7 +17,7 @@ On your Gordon server:
1717 ```bash
1818 gordon auth token generate \
1919 --subject gitlab-ci \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \
20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2121 --expiry 0
2222 ```
2323
@@ -48,7 +48,7 @@ deploy:
4848 - gordon push --build
4949 --remote "$GORDON_REMOTE"
5050 --token "$GORDON_TOKEN"
51- --no-confirm
51+
5252 only:
5353 - tags
5454 ```
@@ -76,7 +76,7 @@ deploy:
7676 - gordon push --build
7777 --remote "$GORDON_REMOTE"
7878 --token "$GORDON_TOKEN"
79- --no-confirm
79+
8080 rules:
8181 - if: $CI_COMMIT_TAG
8282 ```
@@ -99,7 +99,7 @@ deploy:
9999 - gordon push --build
100100 --remote "$GORDON_REMOTE"
101101 --token "$GORDON_TOKEN"
102- --no-confirm
102+
103103 rules:
104104 - if: $CI_COMMIT_BRANCH == "main"
105105 ```
@@ -120,7 +120,7 @@ deploy:
120120 - gordon push --build
121121 --remote "$GORDON_REMOTE"
122122 --token "$GORDON_TOKEN"
123- --no-confirm
123+
124124 when: manual
125125 ```
126126
@@ -142,7 +142,7 @@ deploy:
142142 --token "$GORDON_TOKEN"
143143 --build-arg NODE_ENV=production
144144 --build-arg API_URL=https://api.example.com
145- --no-confirm
145+
146146 rules:
147147 - if: $CI_COMMIT_TAG
148148 ```
@@ -166,7 +166,7 @@ deploy:
166166 ```
167167
168168 This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.
169-Gordon auto-deploys when it receives the image.
169+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
170170
171171 ## Version Detection
172172
M docs/deployment/index.md +42 -68
@@ -1,78 +1,68 @@
11 # Deployment Overview
22
3-Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure.
3+Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests.
44
5-## Recommended: gordon push
5+## Recommended: gordon push + apps deploy
66
7-`gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines.
7+`gordon push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI.
88
9-- Single command: build + push + deploy
109 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
1110 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
1211 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
1312
1413 ```bash
15-gordon push --build --remote https://gordon.example.com --no-confirm
14+gordon push --build --remote https://gordon.example.com
15+```
16+
17+Then activate (from CI with the Gordon binary, or from your machine):
18+
19+```bash
20+gordon apps apply --file blog.toml --remote https://gordon.example.com
21+gordon apps deploy blog --remote https://gordon.example.com
1622 ```
1723
1824 ## All Deployment Methods
1925
2026 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
2127 |--------|----------|----------------|-----------------|----------------|
22-| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
23-| `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) |
24-| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
28+| `gordon push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
29+| `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
2530
26-### Method 1: gordon push (Recommended)
31+### Method 1: gordon push + apps deploy (Recommended)
2732
28-The Gordon CLI handles authentication, image building, and deployment in a single step.
33+The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.
2934
3035 - Single token handles everything: admin API access + registry auth via automatic token exchange
3136 - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`
32-- Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment)
3337 - Requires the Gordon binary on the CI runner
3438
3539 ```bash
36-# Build, push, and deploy
37-gordon push --build --remote https://gordon.example.com --no-confirm
40+# Build and push (OCI transfer only)
41+gordon push --build --remote https://gordon.example.com
3842
39-# Push only, no deploy
40-gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm
43+# Apply the manifest that references the pushed tag, then deploy
44+gordon apps apply --file blog.toml --remote https://gordon.example.com
45+gordon apps deploy blog --remote https://gordon.example.com
4146 ```
4247
43-### Method 2: docker push
48+### Method 2: docker push + apps deploy
4449
45-Standard Docker workflow — no Gordon binary required on the runner.
50+Standard Docker workflow — no Gordon binary required on the runner for the push itself.
4651
4752 - Use `docker login`, `docker build`, and `docker push` as usual
48-- Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed)
49-- No Gordon binary needed on the CI runner
53+- Pushing only stores the image; deploy explicitly with the Gordon CLI afterwards
5054 - Registry endpoint is `gordon.example.com` (not a separate registry host)
5155
5256 ```bash
5357 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
5458 docker build -t gordon.example.com/myapp:v1.2.0 .
5559 docker push gordon.example.com/myapp:v1.2.0
60+# Then: gordon apps apply --file blog.toml + gordon apps deploy blog
5661 ```
5762
58-### Method 3: Docker labels + auto-route
59-
60-Add a `gordon.domain` label to your image and Gordon creates the route automatically on push.
61-
62-- Add `gordon.domain=app.example.com` label to your Dockerfile
63-- Push image — Gordon creates or updates the route without manual config
64-- Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml`
65-- Best for GitOps workflows where routes are defined alongside the application
66-
67-```dockerfile
68-LABEL gordon.domain="app.example.com"
69-```
70-
71-See [Auto-Route](../config/auto-route.md) for configuration details.
72-
7363 ## Registry Access
7464
75-Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`.
65+Clients use the main Gordon domain over HTTPS, normally on port 443. Gordon binds `server.registry_port` to `127.0.0.1`; the public edge proxies authenticated registry requests to that loopback listener. Do not publish or forward the loopback registry port. With `auth.enabled=false`, registry access is loopback-only and the public edge refuses registry-domain requests.
7666
7767 ### Network Topologies
7868
@@ -87,34 +77,27 @@ Gordon's registry is served through the main gordon domain over HTTPS on port 44
8777 See the examples below for the right scopes for each workflow.
8878
8979 ```bash
90-# Minimum scope for gordon push — route lookup + registry push
91-# Server auto-deploys when it receives the image
80+# Push only — registry scopes
9281 gordon auth token generate \
9382 --subject ci-bot \
94- --scopes "push,pull,admin:routes:read" \
83+ --scopes "push,pull" \
9584 --expiry 90d
9685
97-# With explicit CLI-managed deploy (adds config:write for deploy control)
86+# Push + apply/deploy — adds app mutation scopes
9887 gordon auth token generate \
9988 --subject ci-bot \
100- --scopes "push,pull,admin:routes:read,admin:config:write" \
89+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
10190 --expiry 90d
10291
103-# Scoped to a specific repository
92+# Repository-scoped registry token for push only
10493 gordon auth token generate \
10594 --subject ci-bot \
10695 --repo myapp \
107- --scopes "push,pull,admin:routes:read" \
108- --expiry 90d
109-
110-# For docker push — registry scopes only
111-gordon auth token generate \
112- --subject ci-bot \
11396 --scopes "push,pull" \
11497 --expiry 90d
11598 ```
11699
117-Set the generated token as `GORDON_TOKEN` in your CI environment.
100+Set the generated token as `GORDON_TOKEN` in your CI environment. `--repo` limits registry push/pull to that repository; it does not constrain `admin:*` scopes. Use a separate least-privilege token for app administration when repository and deployment duties must be isolated.
118101
119102 ## Version Strategies
120103
@@ -127,26 +110,24 @@ docker tag myapp gordon.example.com/myapp:latest
127110 docker push gordon.example.com/myapp:latest
128111 ```
129112
113+Reference it from the app manifest:
114+
130115 ```toml
131-[routes]
132-"app.example.com" = "myapp:latest"
116+[[service]]
117+name = "web"
118+image = "gordon.example.com/myapp:latest"
133119 ```
134120
135121 ### Semantic Versioning
136122
137-Pin routes to specific versions and update config to roll forward:
123+Pin services to specific versions and apply a new manifest to roll forward:
138124
139125 ```bash
140126 docker tag myapp gordon.example.com/myapp:v2.1.0
141127 docker push gordon.example.com/myapp:v2.1.0
142128 ```
143129
144-```toml
145-[routes]
146-"app.example.com" = "myapp:v2.1.0"
147-```
148-
149-To deploy a new version, update the tag in `gordon.toml` and push the new image.
130+To deploy a new version, update the tag in the app file, apply, and deploy.
150131
151132 ### Git SHA Tags
152133
@@ -158,20 +139,15 @@ docker tag myapp gordon.example.com/myapp:$VERSION
158139 docker push gordon.example.com/myapp:$VERSION
159140 ```
160141
161-## Zero-Downtime Deployment
162-
163-Gordon performs zero-downtime deployments by default:
142+## Updates
164143
165-1. **New container starts** while the old one is still running
166-2. **Health check** waits for the new container to become ready
167-3. **Traffic switches** to the new container
168-4. **Old container stops** after traffic moves
144+For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged.
169145
170146 ```
171147 Timeline ─────────────────────────────────────────>
172148
173149 Old Container: [═══════════════════]
174- ↓ stop
150+ ↓ retire
175151 New Container: [═════════════════════════>
176152 ↑ start ↑ traffic routed
177153 ```
@@ -181,7 +157,5 @@ New Container: [══════════════════
181157 - [GitHub Actions](./github-actions.md)
182158 - [GitLab CI](./gitlab-ci.md)
183159 - [Generic CI](./generic-ci.md)
184-- [Rollback](./rollback.md)
185-- [Routes Configuration](../config/routes.md)
160+- [Apps CLI](../cli/apps.md)
186161 - [Authentication](../config/auth.md)
187-- [Auto-Route](../config/auto-route.md)
M docs/deployment/rollback.md +28 -175
@@ -1,62 +1,28 @@
11 # Rollback
22
3-Roll back to previous versions when deployments fail.
3+Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v2.50: rolling back means applying a manifest that references the previous tag (or re-pushing it) and deploying again.
44
5-## Recommended: Use `gordon pin`
5+## Roll Forward to a Previous Tag
66
7-`gordon pin` is the built-in rollback and roll-forward workflow for route images:
7+The image tags are still in the registry. Point the app file at the last good tag, apply, deploy:
88
99 ```bash
10-gordon pin app.example.com
11-gordon pin app.example.com --tag v2.30.1
12-gordon pin list app.example.com
10+# 1. Edit blog.toml: image = "gordon.mydomain.com/myapp:v2.0.0"
11+gordon apps apply --file blog.toml --remote prod
12+gordon apps deploy blog --remote prod
1313 ```
1414
15-Use it when the target image already exists in the Gordon registry and you want Gordon to update the route and redeploy it for you.
15+For HTTP services without volumes, the previous (broken) container keeps serving until the replacement passes readiness, so the recovery itself is zero-downtime.
1616
17-## Rollback Strategies
17+## Re-push the Previous Image as latest
1818
19-### 1. Config-Based Rollback
20-
21-Update the route to point to the previous version:
22-
23-```toml
24-# Current (broken)
25-[routes]
26-"app.mydomain.com" = "myapp:v2.1.0"
27-
28-# Rollback to previous
29-[routes]
30-"app.mydomain.com" = "myapp:v2.0.0"
31-```
32-
33-Then reload:
34-
35-```bash
36-gordon reload
37-```
38-
39-### 2. Push Previous Version
40-
41-Re-push the previous image with the `latest` tag:
19+When the app file tracks `latest`, re-tag and re-push, then deploy (deploy re-resolves mutable tags):
4220
4321 ```bash
44-docker pull registry.mydomain.com/myapp:v2.0.0
45-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest
46-docker push registry.mydomain.com/myapp:latest
47-```
48-
49-### 3. Manifest-Based Rollback
50-
51-Use OCI manifest annotations for version control:
52-
53-```bash
54-# Rollback to v2.0.0
55-export VERSION=v2.0.0
56-podman manifest create myapp:latest --amend
57-podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
58-podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
59-podman manifest push myapp:latest registry.mydomain.com/myapp:latest
22+docker pull gordon.mydomain.com/myapp:v2.0.0
23+docker tag gordon.mydomain.com/myapp:v2.0.0 gordon.mydomain.com/myapp:latest
24+docker push gordon.mydomain.com/myapp:latest
25+gordon apps deploy blog --remote prod
6026 ```
6127
6228 ## Version Management
@@ -67,169 +33,56 @@ Always push versioned tags alongside `latest`:
6733
6834 ```bash
6935 VERSION=$(git describe --tags)
70-docker tag myapp registry.mydomain.com/myapp:$VERSION
71-docker tag myapp registry.mydomain.com/myapp:latest
72-docker push registry.mydomain.com/myapp:$VERSION
73-docker push registry.mydomain.com/myapp:latest
36+docker tag myapp gordon.mydomain.com/myapp:$VERSION
37+docker tag myapp gordon.mydomain.com/myapp:latest
38+docker push gordon.mydomain.com/myapp:$VERSION
39+docker push gordon.mydomain.com/myapp:latest
7440 ```
7541
7642 ### Semantic Versioning
7743
78-Use semantic versions for clear rollback targets:
44+Use semantic versions for clear recovery targets:
7945
8046 ```
8147 v2.1.0 ← Current (broken)
82-v2.0.0 ← Rollback target
48+v2.0.0 ← Recovery target
8349 v1.9.0 ← Older stable
8450 ```
8551
8652 ### Git SHA Tags
8753
88-Tag with commit SHA for precise rollbacks:
54+Tag with commit SHA for precise recovery:
8955
9056 ```bash
9157 # Deploy
9258 SHA=$(git rev-parse --short HEAD)
93-docker push registry.mydomain.com/myapp:$SHA
94-
95-# Rollback to specific commit
96-docker pull registry.mydomain.com/myapp:abc1234
97-docker tag registry.mydomain.com/myapp:abc1234 registry.mydomain.com/myapp:latest
98-docker push registry.mydomain.com/myapp:latest
99-```
100-
101-## Rollback Workflow
102-
103-### Quick Rollback
104-
105-```bash
106-# 1. Identify last working version
107-docker image ls registry.mydomain.com/myapp
108-
109-# 2. Tag as latest
110-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest
111-
112-# 3. Push
113-docker push registry.mydomain.com/myapp:latest
114-```
115-
116-### Config Rollback
117-
118-```bash
119-# 1. Edit config
120-vim ~/.config/gordon/gordon.toml
121-
122-# 2. Change version
123-# "app.mydomain.com" = "myapp:v2.0.0"
124-
125-# 3. Reload
126-gordon reload
127-```
128-
129-## Automated Rollback
130-
131-### GitHub Actions
132-
133-Add rollback capability to your workflow:
134-
135-```yaml
136-name: Rollback
137-
138-on:
139- workflow_dispatch:
140- inputs:
141- version:
142- description: 'Version to rollback to (e.g., v2.0.0)'
143- required: true
144-
145-jobs:
146- rollback:
147- runs-on: ubuntu-latest
148- steps:
149- - name: Login to Registry
150- env:
151- GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
152- GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }}
153- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
154- run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY"
155-
156- - name: Rollback
157- env:
158- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
159- VERSION: ${{ inputs.version }}
160- run: |
161- [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162- REPOSITORY="$GORDON_REGISTRY/myapp"
163- SOURCE_IMAGE="$REPOSITORY:$VERSION"
164- LATEST_IMAGE="$REPOSITORY:latest"
165- SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166- IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167- docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168- LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169- [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170- echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171- exit 1
172- }
173-
174- - name: Summary
175- env:
176- VERSION: ${{ inputs.version }}
177- run: |
178- echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
179- printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
180-```
181-
182-### Rollback Script
183-
184-Create a local rollback script:
185-
186-```bash
187-#!/bin/bash
188-# rollback.sh
189-
190-REGISTRY="registry.mydomain.com"
191-IMAGE="myapp"
192-VERSION=$1
193-
194-if [ -z "$VERSION" ]; then
195- echo "Usage: ./rollback.sh <version>"
196- echo "Available versions:"
197- docker image ls "$REGISTRY/$IMAGE" --format "{{.Tag}}"
198- exit 1
199-fi
200-
201-echo "Rolling back to $IMAGE:$VERSION..."
202-docker pull "$REGISTRY/$IMAGE:$VERSION"
203-docker tag "$REGISTRY/$IMAGE:$VERSION" "$REGISTRY/$IMAGE:latest"
204-docker push "$REGISTRY/$IMAGE:latest"
205-echo "Rollback complete!"
59+docker push gordon.mydomain.com/myapp:$SHA
20660 ```
20761
208-## Verifying Rollback
62+## Verifying Recovery
20963
210-After rollback:
64+After re-deploying the previous version:
21165
21266 ```bash
213-# Check container is running correct version
214-docker inspect gordon-app-mydomain-com | grep Image
67+# Check the app's effective vs observed state
68+gordon apps status blog --remote prod
21569
21670 # Check application responds
21771 curl -I https://app.mydomain.com
21872
21973 # Check logs
220-gordon logs -f
74+gordon apps logs blog --remote prod
22175 ```
22276
22377 ## Best Practices
22478
22579 1. **Always tag versions** - Don't rely solely on `latest`
226-2. **Keep N previous versions** - Maintain rollback options
80+2. **Keep N previous versions** - Maintain recovery options
22781 3. **Test before deploy** - Reduce need for rollbacks
22882 4. **Document known-good versions** - Track stable releases
229-5. **Automate rollback** - Reduce time to recovery
23083
23184 ## Related
23285
23386 - [Deployment Overview](./index.md)
23487 - [GitHub Actions](./github-actions.md)
235-- [Routes Configuration](../config/routes.md)
88+- [Apps CLI](../cli/apps.md)
M docs/getting-started.md +44 -32
@@ -79,11 +79,10 @@ gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
7979 [entrypoints.edge]
8080 address = ":443" # Public smart TCP edge (choose your bind/mapping)
8181 protocol = "smart_tcp"
82-
83-[routes]
84-"app.mydomain.com" = "myapp:latest" # Domain → Image mapping
8582 ```
8683
84+Application workloads are NOT declared in `gordon.toml`. Each app lives in its own standalone TOML file (see step 8). The old `[routes]`, `[attachments]`, `[network_groups]`, `[[services]]`-as-apps, `[service_routes]`, `[auto_route]`, and `[previews]` keys were removed in v2.50 — Gordon refuses to start when any of them is present.
85+
8786 ## 5. Set Up DNS (Including Wildcard)
8887
8988 `gordon_domain` is the single domain used by both the registry and admin API.
@@ -96,8 +95,8 @@ In Cloudflare (or your DNS provider), create:
9695 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
9796
9897 Why wildcard (`*`)?
99-- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
100-- You can add new domains in `[routes]` without creating DNS records one by one.
98+- It automatically covers app hosts like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
99+- You can add new app HTTP hosts without creating DNS records one by one.
101100
102101 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
103102
@@ -129,10 +128,10 @@ sudo loginctl enable-linger $USER
129128
130129 ## 7. Generate a Deploy Token
131130
132-Create a token for remote CLI deploys (skip if auth is disabled):
131+Create a token for remote CLI use (skip if auth is disabled):
133132
134133 ```bash
135-gordon auth token generate --subject deploy --scopes push,pull --expiry 0
134+gordon auth token generate --subject deploy --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 90d
136135 ```
137136
138137 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.
@@ -148,53 +147,66 @@ On your local machine:
148147 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
149148 gordon remotes use prod
150149
151-# Recommended first-time setup
152-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
153-
154-# Then build, push, and deploy
155-gordon push myapp:latest --domain app.example.com --build --no-confirm
150+# Build and push the image (OCI transfer only, never deploys)
151+gordon push myapp --build --remote prod
156152 ```
157153
158-What this command does:
154+Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.
159155
160-- `gordon bootstrap` creates or updates the route, applies requested attachments,
161- and stores environment variables.
162-- This is the recommended path for first deploys because it does not require the
163- route to exist ahead of time.
164-- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.
156+```toml
157+name = "blog"
158+
159+[[service]]
160+name = "web"
161+image = "gordon.mydomain.com/myapp:latest"
162+
163+[[service.http]]
164+host = "app.mydomain.com"
165+port = 3000
166+```
165167
166-If the route already exists and you only need to push a new image version, use:
168+Apply the manifest, then deploy:
167169
168170 ```bash
169-gordon push myapp --build --no-confirm
171+gordon apps apply --file blog.toml --remote prod
172+gordon apps deploy blog --remote prod
170173 ```
171174
172-`gordon push` still requires the route to already exist so it can resolve the
173-deploy target.
175+What these commands do:
176+
177+- `gordon push` builds, uploads, and stores the image. It never deploys.
178+- `gordon apps apply` validates the manifest and persists it as desired state.
179+- `gordon apps deploy` activates the accepted revision: pulls the image, starts the container, waits for readiness, switches traffic, retires the old container.
180+
181+If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file:
182+
183+```bash
184+gordon apps secrets set blog --service web APP_ENV=production --remote prod
185+```
174186
175187 Your app is now live at `https://app.mydomain.com`!
176188
177189 ## 9. Update Your App
178190
179-Push a new image to deploy with zero downtime:
191+Push a new image, update the manifest tag if needed, apply, deploy:
180192
181193 ```bash
182-# Make changes, then build + push + deploy
183-gordon push myapp --build --no-confirm
194+# Make changes, then build + push
195+gordon push myapp --build --remote prod
196+
197+# Deploy the new tag (re-resolves mutable tags)
198+gordon apps deploy blog --remote prod
184199 ```
185200
186-Gordon automatically:
187-1. Starts the new container
188-2. Waits for it to be ready
189-3. Routes traffic to the new container
190-4. Stops the old container
201+For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. TCP/UDP and volume-owning services replace with interruption.
191202
192203 ## Next Steps
193204
194205 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers
195-- [Configuration Reference](./config/index.md) - All configuration options
206+- [Configuration Reference](./config/index.md) - All installation configuration options
207+- [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands
196208 - [Authentication](./config/auth.md) - Secure your registry
197-- [Environment Variables](./config/env.md) - Configure per-app settings
209+- [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass
198210
199211 ## Related
200212
M docs/index.md +10 -11
@@ -1,15 +1,16 @@
11 # Gordon Documentation
22
3-Gordon is a self-hosted container deployment platform that combines a private Docker registry with automatic container deployment.
3+Gordon is a self-hosted container deployment platform: a private container registry, a declarative app runtime, and a reverse proxy.
44
55 ## What is Gordon?
66
77 Gordon runs on your VPS and provides:
88
99 - **Private Container Registry** - Push images from your local machine or CI
10-- **HTTP Reverse Proxy** - Routes domains to containers automatically
11-- **Push-to-Deploy** - Containers deploy when you push new images
12-- **Zero-Downtime Updates** - New containers start before old ones stop
10+- **Declarative Apps** - One TOML file per app, explicit apply then deploy
11+- **HTTP Reverse Proxy** - Routes app hosts to containers
12+- **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13+- **HTTP Zero-Downtime Updates** - Old containers serve until replacements pass readiness
1314 - **Single Binary** - ~15MB RAM footprint
1415
1516 ## How It Works
@@ -31,7 +32,7 @@ Gordon runs on your VPS and provides:
3132
3233 1. Build your container locally where you have computing power
3334 2. Push to your Gordon registry
34-3. Gordon automatically deploys and routes traffic to your container
35+3. Apply the app manifest and deploy to activate it
3536
3637 ## Quick Navigation
3738
@@ -46,15 +47,13 @@ Gordon runs on your VPS and provides:
4647
4748 - [Configuration Overview](./config/index.md) - All configuration options
4849 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49-- [Routes](./config/routes.md) - Domain to container mapping
50+- [App Manifest](./config/apps.md) - Declarative app files (services, hosts, secrets, volumes)
51+- [Migrate to Gordon v2.50](./migrate-to-v2.50.md) - Breaking upgrade and explicit secret migration
5052 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
5153 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
52-- [Secrets](./config/secrets.md) - Secure credential storage
53-- [Network Isolation](./config/network-isolation.md) - Per-app network isolation
54-- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
55-- [Attachments](./config/attachments.md) - Service dependencies
54+- [Secrets](./config/secrets.md) - Installation secrets and app secret values
55+- [Network Isolation](./config/network-isolation.md) - Installation network policy
5656 - [Logging](./config/logging.md) - Log collection and rotation
57-- [Environment Variables](./config/env.md) - Per-route environment configuration
5857
5958 ### CLI Reference
6059
M docs/installation.md +40 -10
@@ -29,7 +29,25 @@ Detailed installation guide for production environments.
2929 )
3030 ```
3131
32-Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
32+Download and inspect the installer before executing it. The installer verifies the release archive checksum, detects Linux/macOS and amd64/arm64, and installs Gordon to `~/.local/bin` without `sudo`. If that directory is absent from `PATH`, an interactive install can offer to add an idempotent marker block to Fish (`~/.config/fish/config.fish`), Bash (`~/.bashrc`), or Zsh (`~/.zshrc`). Restart the shell after accepting, or follow the printed command to update the current shell.
33+
34+For unattended installs, control PATH configuration explicitly:
35+
36+```bash
37+# Update supported shell configuration without prompting
38+curl -fsSL https://gordon.bnema.dev/install | GORDON_UPDATE_PATH=1 sh
39+
40+# Never modify shell configuration
41+curl -fsSL https://gordon.bnema.dev/install | GORDON_UPDATE_PATH=0 sh
42+
43+# Use another user-local directory
44+curl -fsSL https://gordon.bnema.dev/install | GORDON_INSTALL_DIR="$HOME/bin" GORDON_UPDATE_PATH=1 sh
45+
46+# Explicit global installation (may request sudo)
47+curl -fsSL https://gordon.bnema.dev/install | GORDON_INSTALL_DIR=/usr/local/bin GORDON_UPDATE_PATH=0 sh
48+```
49+
50+`GORDON_INSTALL_DIR` accepts arbitrary safe absolute destinations, including `"$HOME/bin"`, `"$HOME/.local/bin"`, and `/usr/local/bin`. PATH detection and shell configuration use that effective directory. Relative paths, PATH separators, and control characters are rejected. `GORDON_UPDATE_PATH` accepts only `0` or `1`. Do not run the default installer through `sudo`: it refuses to infer a user home and install silently under `/root`.
3351
3452 ### Manual Installation
3553
@@ -136,6 +154,16 @@ insecure = true
136154 EOF
137155 ```
138156
157+Podman assigns loopback backend ports from the host's ephemeral port range when Gordon publishes managed service ports. If an nftables output policy restricts loopback access, allow the Gordon daemon's primary UID—not its subordinate container UIDs—to connect to that range for the protocols used by managed services:
158+
159+```nftables
160+# Confirm the range with: sysctl net.ipv4.ip_local_port_range
161+meta skuid <gordon-uid> ip daddr 127.0.0.1 tcp dport 32768-60999 accept
162+meta skuid <gordon-uid> ip daddr 127.0.0.1 udp dport 32768-60999 accept
163+```
164+
165+Place these rules before any broader loopback rejection. Keep public entrypoint and administrative-port policy separate; these rules only let Gordon reach rootless Podman backends on the local host.
166+
139167 ## Firewall Configuration
140168
141169 Gordon needs ports accessible for the registry and the public edge entrypoint.
@@ -195,11 +223,10 @@ gordon_domain = "gordon.yourdomain.com"
195223 [entrypoints.edge]
196224 address = ":443"
197225 protocol = "smart_tcp"
198-
199-[routes]
200-"app.yourdomain.com" = "myapp:latest"
201226 ```
202227
228+Application workloads live in standalone app files, not in `gordon.toml` (see [Getting Started](./getting-started.md#8-deploy-your-first-app)).
229+
203230 See [Configuration Reference](./config/index.md) for all options.
204231
205232 ## Systemd Service
@@ -310,17 +337,20 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
310337
311338 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.
312339
313-## Installing a Specific Version or Pre-Release
340+## Choosing an Install Channel
314341
315342 ```bash
316-# Install an exact version
317-curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash
343+# Install an exact release
344+curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 sh
345+
346+# Install the latest pre-release
347+curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 sh
318348
319-# Install the latest pre-release instead of the latest stable release
320-curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
349+# Build the current next branch commit from source
350+curl -fsSL https://gordon.bnema.dev/install | GORDON_CHANNEL=next sh
321351 ```
322352
323-By default, the installer resolves `latest` to the newest stable release and verifies the downloaded checksum before installing.
353+Stable, exact-version, and pre-release installs download release binaries and verify their published checksums. The `next` channel is an **unverified development source build**: it resolves the branch through the GitHub API, pins the resulting commit SHA, downloads that exact source snapshot, and builds it locally for the detected platform. It requires the Go version declared by that commit's `go.mod`, is not covered by release checksums, and may be unstable. Do not combine `GORDON_CHANNEL=next` with `GORDON_VERSION` or `GORDON_PRERELEASE`.
324354
325355 ## Verify Installation
326356
A docs/migrate-to-v2.50.md +145 -0
@@ -0,0 +1,145 @@
1+# Migrate to Gordon v2.50
2+
3+Gordon v2.50 replaces domain-based route workloads with declarative apps. The upgrade is intentionally explicit: Gordon does not infer app ownership from domains, does not adopt existing volumes, and does not copy domain secrets into app secrets automatically.
4+
5+Use this guide before starting the v2.50 daemon with production traffic.
6+
7+## Before the upgrade
8+
9+1. Stop deployments and automatic image pruning.
10+2. Back up Gordon's configuration, state, registry, databases, volumes, and secret store using your existing procedures.
11+3. Record every current domain, image tag or digest, attachment, network relationship, volume, and secret key.
12+4. Keep the previous signed Gordon binary and its matching configuration and state backup available for rollback.
13+
14+Do not delete old containers, volumes, pass entries, or registry tags during the migration. Gordon's ownership-aware reconciliation and prune paths preserve resources whose ownership cannot be proven. Runtime-native cleanup commands and manual deletion do not provide that guarantee.
15+
16+## 1. Update the installation configuration
17+
18+Remove workload declarations that v2.50 no longer accepts from `gordon.toml`:
19+
20+- `[routes]`;
21+- `[attachments]`;
22+- `[network_groups]`;
23+- app-like `[[services]]` and `[service_routes]`;
24+- `[auto_route]` and `[auto_route_allowed_domains]`;
25+- `[previews]`.
26+
27+Keep installation-level settings such as entrypoints, TLS, limits, registry policy, external routes, backup destinations, logging, and authentication.
28+
29+Review every app image registry before applying manifests. v2.50 allows Docker Hub (`docker.io`, including its canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry by default. Add each other registry, including private registries, as an exact hostname and optional non-default port:
30+
31+```toml
32+[images]
33+allowed_registries = ["registry.internal:5000"]
34+```
35+
36+Hostname matching is case-insensitive, ignores one trailing dot, and treats port `443` as the default. The allowlist is checked during manifest apply, deployment preflight, and immediately before pull. It controls registry names only: it does not prove that DNS resolves to a public IP or enforce runtime egress. Use firewall or runtime network policy when destination-level restrictions are required.
37+
38+Create one app manifest per workload. See [App Manifest](./config/apps.md).
39+
40+```toml
41+name = "example-app"
42+
43+[[service]]
44+name = "web"
45+image = "registry.example.com/example-app:v2.50.0"
46+
47+[service.secrets]
48+DATABASE_URL = "database-url"
49+
50+[[service.http]]
51+host = "app.example.com"
52+port = 8080
53+```
54+
55+The manifest contains secret **names**, never secret values.
56+
57+## 2. Migrate domain secrets
58+
59+Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[service.secrets]`, then apply the manifest before setting values:
60+
61+```bash
62+gordon apps apply --file ./example-app.toml --remote production
63+printf 'DATABASE_URL=%s\n' "$(pass show gordon/env/app_example_com/DATABASE_URL)" \
64+ | gordon apps secrets set example-app --service web --stdin --remote production
65+```
66+
67+Pass values through standard input. Do not put them in manifests, command arguments, logs, shell history, or temporary files. Repeat explicitly when several services need the same value.
68+
69+Deploy and restart the app to verify that it uses the new app-scoped entries. Keep the old `gordon/env/...` entries throughout the rollback window; Gordon never uses them as a fallback. Refer to the `pass` documentation for password-store and GPG backup or restore procedures.
70+
71+## 3. Transfer existing volume data
72+
73+Gordon's reconciliation and prune paths leave existing unowned volumes untouched, but Gordon does not adopt them. After applying the manifest, let Gordon create the new app-owned volumes, then transfer the data with your container runtime's tools while the workload is stopped. Do not run runtime-wide volume prune, edit `state.db`, rename volumes, or alter ownership labels.
74+
75+Use a database-native backup and restore for databases instead of copying live database files. Keep the old volumes unchanged until the new app has passed data, deploy, restart, and rollback-readiness checks. Refer to the Docker or Podman documentation for runtime-specific copy and inspection commands.
76+
77+## 4. Apply and deploy apps
78+
79+For every manifest:
80+
81+```bash
82+gordon apps apply --file ./example-app.toml --remote production
83+gordon apps diff example-app --remote production
84+gordon apps deploy example-app --remote production
85+gordon apps status example-app --json --remote production
86+```
87+
88+Push only uploads OCI content in v2.50; it does not deploy. Apply and deploy explicitly.
89+
90+## 5. Replace tokens and automation
91+
92+Replace removed route scopes with app scopes:
93+
94+- `admin:apps:read` for list, show, diff, and status;
95+- `admin:apps:write` for apply, deploy, lifecycle, and app secrets.
96+
97+Generate replacement CI and operator tokens with the minimum required scopes. Update automation that expected push-to-deploy, route mutation commands, previews, attachments, bootstrap, autoroute, pin, or rollback commands.
98+
99+To roll back an application release in v2.50, apply a manifest containing the previous image tag and deploy it.
100+
101+## 6. Validate before opening traffic
102+
103+Verify all of the following:
104+
105+- each app is converged on the expected pinned digest;
106+- HTTP, TCP, and UDP entrypoints work as applicable;
107+- app secrets survive a restart and no value appears in output or logs;
108+- named volumes contain the expected data after deploy and restart;
109+- stopped intent survives a daemon restart;
110+- retained and unknown resources remain untouched;
111+- `gordon images prune --dry-run` reports protected app content correctly before any real prune;
112+- the previous binary and matching data backup remain available.
113+
114+Run destructive prune commands only after their dry-run candidate list matches the expected ownership and retention policy.
115+
116+## Security guarantees to rely on after the upgrade
117+
118+- **Containment first.** Keep `auth.enabled = true` and automatic registry prune disabled (`images.prune.enabled = false`) until the upgraded build is running and validated. A disabled-auth registry is local-only: public ingress cannot reach it through the proxy.
119+- **Network isolation.** Every app container joins an app-owned private network; shared memberships come only from explicit manifest declarations, and container memory/CPU/PID limits are applied on create and recovery.
120+- **Registry content is repository-scoped.** Pulls require the repository to have completed the upload of a blob, and manifests may only reference content that repository owns. Content uploaded by an older build has no durable ownership marker, so a repository whose blobs predate this build must re-push its images before they can be pulled again.
121+- **Runtime image prune needs released ownership.** An app's pinned images are recorded as attached while deployed and released when the app is removed. Dangling images with no durable ownership record are never deleted, and image labels alone never authorize deletion.
122+- **Durable roots keep their closure.** A digest pinned by active/desired state, an apply intent, an operation, or ownership retains its manifest, config, and layers even if the tag moves away.
123+- **Plaintext TLS policy.** Hosts declared `tls = always` never receive plaintext traffic: they redirect when an HTTPS endpoint exists and are refused with `421` when none does.
124+- **Failure output is log-free.** Mutation responses and operation lookup for `admin:apps:read` carry stable errors only; application diagnostics require `admin:logs:read` and are redacted before storage.
125+
126+## Rollback
127+
128+A binary downgrade against v2.50 app state is unsupported. To roll back:
129+
130+1. stop the v2.50 daemon;
131+2. restore the previous signed binary;
132+3. restore its matching configuration and state backup as one set;
133+4. restore secret-store entries if any were removed;
134+5. restart with automatic pruning disabled;
135+6. verify workloads and data before reopening traffic.
136+
137+Do not attempt rollback by adopting unknown containers or volumes into v2.50 state.
138+
139+## Related
140+
141+- [Upgrading Gordon](./upgrading.md)
142+- [App Manifest](./config/apps.md)
143+- [Apps CLI](./cli/apps.md)
144+- [Secrets](./config/secrets.md)
145+- [Authentication](./config/auth.md)
M docs/reference/docker-labels.md +22 -150
@@ -4,122 +4,57 @@ Labels used by Gordon for container and image metadata.
44
55 ## Container Labels
66
7-Gordon adds these labels to managed containers:
7+Gordon stamps these ownership labels on every container it creates:
88
99 | Label | Value | Description |
1010 |-------|-------|-------------|
1111 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |
12-| `gordon.domain` | Domain name | Domain this container serves |
13-| `gordon.image` | Image:tag | Original image from configuration |
14-| `gordon.route` | Domain name | Route this container handles |
12+| `gordon.app` | App name | App this container serves |
13+| `gordon.app.service` | Service name | Service this container runs |
14+| `gordon.app.revision` | Revision | Active revision that created it |
1515 | `gordon.created` | Timestamp | When Gordon created the container |
1616
17-### Propagated Image Labels
18-
19-When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing):
20-
21-| Label | Example | Description |
22-|-------|---------|-------------|
23-| `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to |
24-| `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing |
25-
26-These labels only appear on containers whose source image defined them.
17+Queries by logical identity use labels, never name parsing. Resources without these labels (old or foreign containers) are preserved, never adopted or deleted.
2718
28-### Attachment Labels
19+### Legacy Labels
2920
30-Additional labels for attachment containers:
21+Containers created before v2.50 may carry these labels. They are read-only provenance hints for prune guards — Gordon never infers app state from them:
3122
3223 | Label | Value | Description |
3324 |-------|-------|-------------|
34-| `gordon.attachment` | `"true"` | Container is an attachment service |
35-| `gordon.attached-to` | Domain/group | Route or network group this serves |
36-
37-### Backup Labels
25+| `gordon.domain` | Domain name | Pre-v2.50 domain this container served |
26+| `gordon.image` | Image:tag | Original image from configuration |
27+| `gordon.route` | Domain name | Pre-v2.50 route this container handled |
3828
39-Labels used by the backup subsystem:
29+### Backup Metadata
4030
41-| Label | Value | Description |
42-|-------|-------|-------------|
43-| `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container |
44-| `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override |
45-| `gordon.backup.version` | e.g. `"17"` | Explicit database version override |
46-| `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint |
47-| `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers |
31+Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[service.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars.
4832
4933 ## Image Labels
5034
51-Labels you can set in your Dockerfile:
52-
53-| Label | Example | Description |
54-|-------|---------|-------------|
55-| `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
56-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
57-| `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
58-| `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
59-
60-### Health Check Label
61-
62-When `gordon.health` is set, Gordon performs HTTP GET requests to the specified
63-path during deployment and waits for a 2xx or 3xx response before routing traffic
64-to the new container:
65-
66-```dockerfile
67-FROM node:20-alpine
68-LABEL gordon.health="/api/health"
69-EXPOSE 3000
70-CMD ["node", "server.js"]
71-```
72-
73-Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful
74-response or the `deploy.http_probe_timeout` is reached.
75-
76-### Proxy Port Label
77-
78-When an image exposes multiple ports, Gordon needs to know which one serves HTTP:
79-
80-```dockerfile
81-# Gitea exposes SSH (22) and HTTP (3000)
82-FROM gitea/gitea:latest
83-LABEL gordon.proxy.port=3000
84-EXPOSE 22
85-EXPOSE 3000
86-```
87-
88-Without this label, Gordon uses the first exposed port.
89-
90-**When to use:**
91-- Image exposes multiple ports
92-- First exposed port isn't the HTTP service
93-- You want explicit control over routing
35+No image-label inference exists in v2.50: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[service.readiness]`, `[[service.http]]`), not from image labels.
9436
9537 ## Container Naming
9638
97-Gordon names containers:
98-
99-| Pattern | Example |
100-|---------|---------|
101-| `gordon-{domain}` | `gordon-app-mydomain-com` |
102-| `gordon-{domain}-new` | `gordon-app-mydomain-com-new` (during updates) |
103-| `gordon-{domain}-{service}` | `gordon-app-mydomain-com-postgres` (attachments) |
39+Gordon names containers `gordon-<app>--<service>--<instance>` where `<instance>` is the creating operation ID. Retiring containers keep their instance names until removal.
10440
10541 ## Inspecting Labels
10642
10743 View labels on a container:
10844
10945 ```bash
110-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | jq
46+docker inspect <container> --format '{{json .Config.Labels}}' | jq
11147 ```
11248
11349 Example output:
11450
11551 ```json
11652 {
117- "gordon.created": "2024-01-15T10:30:00Z",
118- "gordon.domain": "app.mydomain.com",
119- "gordon.image": "myapp:latest",
12053 "gordon.managed": "true",
121- "gordon.proxy.port": "3000",
122- "gordon.route": "app.mydomain.com"
54+ "gordon.app": "blog",
55+ "gordon.app.service": "web",
56+ "gordon.app.revision": "rev-3",
57+ "gordon.created": "2024-01-15T10:30:00Z"
12358 }
12459 ```
12560
@@ -131,75 +66,12 @@ Find Gordon-managed containers:
13166 # All Gordon containers
13267 docker ps -f "label=gordon.managed=true"
13368
134-# Containers for specific domain
135-docker ps -f "label=gordon.domain=app.mydomain.com"
136-
137-# Attachment containers
138-docker ps -f "label=gordon.attachment=true"
139-
140-# Attachments for specific route
141-docker ps -f "label=gordon.attached-to=app.mydomain.com"
142-
143-# Backup sidecars
144-docker ps -f "label=gordon.backup.sidecar=true"
145-```
146-
147-## Examples
148-
149-### Standard Application Container
150-
151-```bash
152-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}'
153-```
154-
155-```json
156-{
157- "gordon.managed": "true",
158- "gordon.domain": "app.mydomain.com",
159- "gordon.image": "myapp:v2.1.0",
160- "gordon.route": "app.mydomain.com",
161- "gordon.created": "2024-01-15T10:30:00Z"
162-}
163-```
164-
165-### Attachment Container
166-
167-```bash
168-docker inspect gordon-app-mydomain-com-postgres --format '{{json .Config.Labels}}'
69+# Containers for a specific app
70+docker ps -f "label=gordon.app=blog"
16971 ```
17072
171-```json
172-{
173- "gordon.managed": "true",
174- "gordon.attachment": "true",
175- "gordon.attached-to": "app.mydomain.com",
176- "gordon.created": "2024-01-15T10:29:00Z"
177-}
178-```
179-
180-### Multi-Port Dockerfile
181-
182-```dockerfile
183-FROM node:18
184-LABEL gordon.proxy.port=3000
185-
186-WORKDIR /app
187-COPY . .
188-
189-# HTTP server on 3000
190-EXPOSE 3000
191-# Metrics on 9090
192-EXPOSE 9090
193-# WebSocket on 8080
194-EXPOSE 8080
195-
196-CMD ["npm", "start"]
197-```
198-
199-Gordon routes HTTP to port 3000.
200-
20173 ## Related
20274
20375 - [Configuration Overview](../config/index.md)
204-- [Attachments](../config/attachments.md)
76+- [App Manifest](../config/apps.md)
20577 - [Concepts](../concepts.md)
M docs/reference/troubleshooting.md +16 -30
@@ -118,17 +118,13 @@ No restart needed — Podman reads this on each pull/push.
118118
119119 ### "unknown: image not found"
120120
121-**Cause:** Image was pushed but no route configured.
121+**Cause:** The app manifest references an image that is unavailable to the runtime.
122122
123-**Solution:** Add route to config:
124-```toml
125-[routes]
126-"app.mydomain.com" = "myapp:latest"
127-```
123+**Solution:** Push the image to the configured registry, verify the service's `image` reference in the app manifest, then apply and deploy the accepted revision:
128124
129-Then reload:
130125 ```bash
131-gordon reload
126+gordon apps apply --file app.toml
127+gordon apps deploy app
132128 ```
133129
134130 ## Deployment Issues
@@ -161,9 +157,9 @@ gordon reload
161157
162158 **Solutions:**
163159
164-1. Check container logs:
160+1. Check workload logs:
165161 ```bash
166- docker logs gordon-app-mydomain-com
162+ gordon apps logs blog --service web
167163 ```
168164
169165 2. Check Gordon logs:
@@ -234,18 +230,14 @@ gordon reload
234230
235231 **Solutions:**
236232
237-1. Check attachments are configured:
238- ```toml
239- [attachments]
240- "app.mydomain.com" = ["postgres:latest"]
241- ```
233+1. Check that both services declare the same shared network in the app manifest.
242234
243-2. Check containers are in same network:
235+2. Check containers are attached to that network:
244236 ```bash
245- docker network inspect gordon-app-mydomain-com
237+ docker network inspect NETWORK_NAME
246238 ```
247239
248-3. Use correct hostname (image name before colon):
240+3. Use the service name as the internal hostname:
249241 ```javascript
250242 // Correct
251243 connect("postgresql://postgres:5432/mydb")
@@ -369,15 +361,9 @@ enabled = true
369361 path = "~/.gordon/logs/gordon.log"
370362 ```
371363
372-### Container logs missing
373-
374-**Cause:** Container log collection disabled.
364+### Workload logs unavailable
375365
376-**Solution:**
377-```toml
378-[logging.container_logs]
379-enabled = true # default: true
380-```
366+`gordon apps logs APP --service SERVICE` reads directly from the container runtime. Confirm that the app has an active deployment, use the exact service name, and check the runtime's logging driver and retention settings. Gordon does not write workload logs to `logging.container_logs` files.
381367
382368 ## Diagnostic Commands
383369
@@ -398,11 +384,11 @@ docker network ls | grep gordon
398384 # List volumes
399385 docker volume ls | grep gordon
400386
401-# Check container logs
402-docker logs gordon-app-mydomain-com
387+# Check workload logs through Gordon
388+gordon apps logs blog --service web
403389
404-# Inspect container
405-docker inspect gordon-app-mydomain-com
390+# Inspect runtime containers when diagnosing locally
391+docker ps -f "label=gordon.app=blog"
406392
407393 # Check connectivity
408394 curl -v http://localhost:5000/v2/
M docs/upgrading.md +26 -4
@@ -2,6 +2,28 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
5+## v2.50.0: Declarative Apps (breaking)
6+
7+v2.50 replaces route-container management with declarative apps. One standalone TOML file defines one app; `gordon apps apply` persists desired state and `gordon apps deploy` activates it. Push transfers OCI content only and never deploys.
8+
9+Follow [Migrate to Gordon v2.50](./migrate-to-v2.50.md) for the complete cutover procedure, including explicit migration from domain-scoped secrets to app- and service-scoped secrets.
10+
11+### Removed
12+
13+- `[routes]`, `[attachments]`, `[network_groups]`, `[[services]]`-as-apps, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), `network_services`-as-apps, `[previews]` keys in `gordon.toml`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration.
14+- CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`.
15+- Scopes `admin:routes:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`.
16+- No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.
17+
18+### Manual migration
19+
20+1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted.
21+2. Delete the removed keys from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay).
22+3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[service.http]]` hosts, `[service.secrets]` names, volumes, `[[network.shared]]`, backup declarations.
23+4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v2.50 secrets migration procedure](./migrate-to-v2.50.md#2-migrate-domain-secrets).
24+5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.
25+6. Staging is an ordinary app in another file. A binary downgrade against the new app-state format is unsupported: restore the old installation/config/state and backups through an operator-approved procedure.
26+
527 ## Route-Domain Validation
628
729 Route keys must be plain hostnames. Use inline tables like `"app.example.com" = { image = "myapp:latest" }`. Gordon still reads legacy `http://...` route entries for backward compatibility and rewrites them on the next save. Update `[routes]`, CLI commands, and automation that reference the old values.
@@ -114,7 +136,7 @@ registry_domain = "gordon.example.com"
114136 gordon_domain = "gordon.example.com"
115137 ```
116138
117-If you do not migrate, `gordon status --remote ...` and `gordon routes list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.
139+If you do not migrate, `gordon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.
118140
119141 ### Staged Registry Host Rename
120142
@@ -214,7 +236,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
214236 - `gordon auth show-token` prints the stored token for a remote
215237 - `gordon auth logout` removes the stored token locally
216238 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
217-- Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
239+- Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations
218240
219241 **Migration steps:**
220242
@@ -294,8 +316,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi
294316 # Read-only monitoring
295317 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
296318
297-# CI deploy with route read + config write
298-gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0
319+# CI deploy with app read + write
320+gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0
299321 ```
300322
301323 See [Token Scopes](./config/auth.md#token-scopes) for the full list.
M go.mod +2 -1
@@ -32,6 +32,7 @@ require (
3232 github.com/spf13/cobra v1.10.2
3333 github.com/spf13/viper v1.21.0
3434 github.com/stretchr/testify v1.12.1
35+ go.etcd.io/bbolt v1.5.0
3536 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
3637 go.opentelemetry.io/otel v1.46.0
3738 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0
@@ -43,7 +44,6 @@ require (
4344 go.opentelemetry.io/otel/sdk/metric v1.46.0
4445 go.opentelemetry.io/otel/trace v1.46.0
4546 go.uber.org/mock v0.6.0 // direct
46- golang.org/x/mod v0.40.0
4747 golang.org/x/sync v0.22.0
4848 golang.org/x/sys v0.47.0
4949 golang.org/x/time v0.15.0
@@ -113,6 +113,7 @@ require (
113113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect
114114 go.yaml.in/yaml/v3 v3.0.5 // indirect
115115 golang.org/x/crypto v0.55.0 // indirect
116+ golang.org/x/mod v0.40.0 // indirect
116117 golang.org/x/net v0.58.0 // indirect
117118 golang.org/x/text v0.41.0 // indirect
118119 golang.org/x/tools v0.49.0 // indirect
M go.sum +2 -0 generated file: collapsed
M gordon.toml.example +28 -20
@@ -70,6 +70,9 @@ drain_delay = "2s"
7070 security_profile = "compat"
7171
7272 [network_isolation]
73+# Installation network policy for Gordon-managed networks (prefix filter
74+# for `gordon networks list`). Per-app isolation is removed: apps declare
75+# `[[network.shared]]` membership in their app file instead.
7376 enabled = true
7477 network_prefix = "gordon"
7578 # Set true to create Docker internal networks without direct external egress.
@@ -81,10 +84,10 @@ prefix = "gordon"
8184 preserve = true
8285
8386 [images]
84-# Explicit external registries must be listed here. Gordon's own registry is
85-# always allowed; localhost/private/link-local registries are always rejected.
87+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed.
88+# Add other registries as exact hostname+port entries; this does not configure credentials.
8689 allowed_registries = []
87-# Require @sha256:<digest> for allowlisted external registries.
90+# Require @sha256:<64 hex chars> for images from every registry, including Gordon.
8891 require_digest = false
8992
9093 [images.prune]
@@ -92,9 +95,6 @@ enabled = false
9295 schedule = "daily"
9396 keep_last = 3
9497
95-[auto_route]
96-enabled = false
97-
9898 [logging]
9999 level = "info"
100100 format = "console"
@@ -106,13 +106,6 @@ enabled = false
106106 # max_backups = 3
107107 # max_age = 28
108108
109-[logging.container_logs]
110-enabled = true
111-# dir = "~/.gordon/logs/containers"
112-# max_size = 100
113-# max_backups = 3
114-# max_age = 28
115-
116109 [logging.access_log]
117110 enabled = false
118111 format = "json"
@@ -173,9 +166,27 @@ keep = 14
173166
174167 [external_routes]
175168 # "status.example.com" = "127.0.0.1:9000"
169+#
170+# NOTE: the pre-v2.50 application keys below were removed by the
171+# declarative-apps cutover. Gordon fails boot/reload closed when any of
172+# them is present (see validateRetiredAppConfig in internal/app/run.go).
173+# Declare application workloads in standalone <app>.toml files instead:
174+# one file per app, applied with `gordon apps apply --file <app>.toml`
175+# and activated with `gordon apps deploy <app>`.
176+#
177+# REMOVED: [routes] -> [[service.http]] in an app file
178+# REMOVED: [attachments] -> [[service]] + volumes in an app file
179+# REMOVED: [network_groups] -> [[network.shared]] in an app file
180+# REMOVED: [[services]]-as-app -> one [[service]] per app file
181+# REMOVED: [service_routes] -> [[service.http]] in an app file
182+# REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file
183+# REMOVED: [previews] -> staging is an ordinary app file
176184
177185 # Gordon-managed standalone L4 services. Use service:<name>:<port> from
178-# traffic routers to expose published loopback ports.
186+# traffic routers to expose published loopback ports. Standalone services
187+# are installation-level: they live in gordon.toml, not in app files.
188+# (App services are declared with [[service]] in <app>.toml files and
189+# managed with `gordon apps`; see docs/cli/apps.md.)
179190 # [[services]]
180191 # name = "rust"
181192 # image = "registry.example.com:5000/rust:latest"
@@ -213,9 +224,6 @@ keep = 14
213224 # name = "rust-rcon"
214225 # entrypoint = "rcon"
215226 # service = "service:rust:rcon"
216-
217-[network_groups]
218-# "backend" = ["app.example.com", "api.example.com"]
219-
220-[attachments]
221-# "app.example.com" = ["postgres:18"]
227+#
228+# REMOVED application sections (see note under [external_routes] above):
229+# [routes], [network_groups], [attachments] were deleted with the cutover.
M install.sh +301 -18
@@ -2,12 +2,55 @@
22 set -e
33
44 # Gordon installer script
5-# Usage: curl -fsSL https://gordon.bnema.dev/install | bash
6-# Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
5+# Usage: curl -fsSL https://gordon.bnema.dev/install | sh
6+# Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 sh
7+# Usage with next source build: curl -fsSL https://gordon.bnema.dev/install | GORDON_CHANNEL=next sh
78
89 REPO="bnema/gordon"
9-INSTALL_DIR="/usr/local/bin"
1010 VERSION="${GORDON_VERSION:-latest}"
11+CHANNEL="${GORDON_CHANNEL:-stable}"
12+
13+case "${GORDON_UPDATE_PATH:-}" in
14+ ''|0|1) ;;
15+ *) echo "Error: GORDON_UPDATE_PATH must be 0 or 1"; exit 1 ;;
16+esac
17+
18+if [ -n "${GORDON_INSTALL_DIR+x}" ]; then
19+ INSTALL_DIR=$GORDON_INSTALL_DIR
20+ if [ -z "$INSTALL_DIR" ]; then
21+ echo "Error: GORDON_INSTALL_DIR must not be empty"
22+ exit 1
23+ fi
24+else
25+ if [ -n "${SUDO_USER:-}" ] || [ "$(id -u)" -eq 0 ]; then
26+ echo "Error: Refusing a default user-local install while running as root or through sudo."
27+ echo "Run the installer as the target user, or set GORDON_INSTALL_DIR explicitly for a global install."
28+ exit 1
29+ fi
30+ if [ -z "${HOME:-}" ]; then
31+ echo "Error: HOME is required for the default installation directory"
32+ exit 1
33+ fi
34+ INSTALL_DIR=$HOME/.local/bin
35+fi
36+
37+case "$INSTALL_DIR" in
38+ /*) ;;
39+ *) echo "Error: GORDON_INSTALL_DIR must be an absolute path"; exit 1 ;;
40+esac
41+if [ "$INSTALL_DIR" != "/" ]; then
42+ while [ "${INSTALL_DIR%/}" != "$INSTALL_DIR" ]; do
43+ INSTALL_DIR=${INSTALL_DIR%/}
44+ done
45+fi
46+INSTALL_DIR_WITHOUT_CONTROLS=$(LC_ALL=C printf '%sX' "$INSTALL_DIR" | LC_ALL=C tr -d '\000-\037\177')
47+if [ "$INSTALL_DIR" = "/" ] || [ "$INSTALL_DIR_WITHOUT_CONTROLS" != "${INSTALL_DIR}X" ]; then
48+ echo "Error: GORDON_INSTALL_DIR contains an unsafe control character or destination"
49+ exit 1
50+fi
51+case "$INSTALL_DIR" in
52+ *:*) echo "Error: GORDON_INSTALL_DIR must not contain ':' because it separates PATH entries"; exit 1 ;;
53+esac
1154
1255 echo "Installing Gordon..."
1356
@@ -43,6 +86,257 @@ esac
4386
4487 echo "Detected: ${OS}/${ARCH}"
4588
89+install_binary_atomically() {
90+ binary=$1
91+ destination=$2
92+ destination_dir=${destination%/*}
93+ staging="${destination}.install.$$"
94+
95+ if ! mkdir -p "$destination_dir" 2>/dev/null; then
96+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
97+ echo "Error: Could not create user installation directory ${destination_dir}"
98+ return 1
99+ fi
100+ echo "sudo required to create ${destination_dir}"
101+ sudo mkdir -p "$destination_dir"
102+ fi
103+
104+ if [ -w "$destination_dir" ]; then
105+ trap 'rm -f "$staging"; rm -rf "${TMP_DIR:-}"' EXIT HUP INT TERM
106+ install -m 0755 "$binary" "$staging"
107+ mv -f "$staging" "$destination"
108+ else
109+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
110+ echo "Error: User installation directory is not writable: ${destination_dir}"
111+ return 1
112+ fi
113+ echo "sudo required to install to ${destination_dir}"
114+ sudo install -m 0755 "$binary" "$staging"
115+ if ! sudo mv -f "$staging" "$destination"; then
116+ sudo rm -f "$staging"
117+ return 1
118+ fi
119+ fi
120+}
121+
122+path_contains_install_dir() {
123+ case ":${PATH:-}:" in
124+ *:"$INSTALL_DIR":*) return 0 ;;
125+ *) return 1 ;;
126+ esac
127+}
128+
129+single_quote() {
130+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
131+}
132+
133+print_path_instructions() {
134+ quoted_dir=$(single_quote "$INSTALL_DIR")
135+ echo "Add Gordon to the current shell with:"
136+ case "${SHELL##*/}" in
137+ fish) echo " fish_add_path ${quoted_dir}" ;;
138+ *) echo " export PATH=${quoted_dir}:\$PATH" ;;
139+ esac
140+}
141+
142+update_shell_path() {
143+ shell_name=${SHELL##*/}
144+ marker_start="# >>> Gordon installer PATH >>>"
145+ marker_end="# <<< Gordon installer PATH <<<"
146+
147+ case "$shell_name" in
148+ fish)
149+ config_file=$HOME/.config/fish/config.fish
150+ config_dir=${config_file%/*}
151+ command_line="fish_add_path $(single_quote "$INSTALL_DIR")"
152+ ;;
153+ bash)
154+ config_file=$HOME/.bashrc
155+ config_dir=$HOME
156+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
157+ ;;
158+ zsh)
159+ config_file=$HOME/.zshrc
160+ config_dir=$HOME
161+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
162+ ;;
163+ *)
164+ echo "Could not update PATH automatically: unsupported or missing SHELL (${SHELL:-unset})."
165+ print_path_instructions
166+ return 0
167+ ;;
168+ esac
169+
170+ if ! mkdir -p "$config_dir" 2>/dev/null ||
171+ { [ -e "$config_file" ] && [ ! -w "$config_file" ]; } ||
172+ { [ ! -e "$config_file" ] && [ ! -w "$config_dir" ]; }; then
173+ echo "Could not update PATH automatically: ${config_file} is not writable."
174+ print_path_instructions
175+ return 0
176+ fi
177+
178+ if [ -f "$config_file" ] && grep -F "$marker_start" "$config_file" >/dev/null 2>&1; then
179+ echo "PATH configuration already exists in ${config_file}."
180+ else
181+ {
182+ printf '\n%s\n' "$marker_start"
183+ printf '%s\n' "$command_line"
184+ printf '%s\n' "$marker_end"
185+ } >>"$config_file"
186+ echo "Added ${INSTALL_DIR} to PATH in ${config_file}."
187+ fi
188+ print_path_instructions
189+}
190+
191+post_install() {
192+ echo ""
193+ echo "Gordon installed successfully at ${INSTALL_DIR}/gordon."
194+
195+ if path_contains_install_dir; then
196+ "$INSTALL_DIR/gordon" version
197+ return
198+ fi
199+
200+ case "${GORDON_UPDATE_PATH:-}" in
201+ 1) update_shell_path ;;
202+ 0) print_path_instructions ;;
203+ '')
204+ if [ -r /dev/tty ] && [ -w /dev/tty ]; then
205+ printf 'Add %s to your PATH configuration? [y/N] ' "$INSTALL_DIR" >/dev/tty
206+ if IFS= read -r answer </dev/tty; then
207+ case "$answer" in
208+ y|Y|yes|YES|Yes) update_shell_path ;;
209+ *) print_path_instructions ;;
210+ esac
211+ else
212+ print_path_instructions
213+ fi
214+ else
215+ print_path_instructions
216+ fi
217+ ;;
218+ esac
219+}
220+
221+version_at_least() {
222+ actual=$1
223+ required=$2
224+ actual_major=${actual%%.*}
225+ actual_minor=${actual#*.}
226+ actual_minor=${actual_minor%%.*}
227+ required_major=${required%%.*}
228+ required_minor=${required#*.}
229+ required_minor=${required_minor%%.*}
230+
231+ [ "$actual_major" -gt "$required_major" ] || {
232+ [ "$actual_major" -eq "$required_major" ] && [ "$actual_minor" -ge "$required_minor" ]
233+ }
234+}
235+
236+install_next() {
237+ if [ -n "${GORDON_VERSION:-}" ]; then
238+ echo "Error: GORDON_VERSION cannot be used with GORDON_CHANNEL=next"
239+ exit 1
240+ fi
241+ if [ -n "${GORDON_PRERELEASE:-}" ]; then
242+ echo "Error: GORDON_PRERELEASE cannot be used with GORDON_CHANNEL=next"
243+ exit 1
244+ fi
245+ if ! command -v go >/dev/null 2>&1; then
246+ echo "Error: GORDON_CHANNEL=next requires Go"
247+ exit 1
248+ fi
249+
250+ echo "WARNING: UNVERIFIED DEVELOPMENT BUILD"
251+ echo "The next channel builds source locally from the current next branch commit."
252+ echo "It is not covered by the signed release checksum path and may be unstable."
253+ echo "Resolving the current next commit..."
254+ COMMIT_DATA=$(curl -fsSL -H "Accept: application/vnd.github+json" \
255+ "https://api.github.com/repos/${REPO}/commits/next" 2>/dev/null || echo "")
256+ COMMIT=$(printf '%s\n' "$COMMIT_DATA" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]*\)".*/\1/p' | head -n 1)
257+ case "$COMMIT" in
258+ *[!0-9a-fA-F]*|'') COMMIT="" ;;
259+ esac
260+ if [ "${#COMMIT}" -ne 40 ]; then
261+ echo "Error: Could not resolve an exact next commit SHA from the GitHub API"
262+ exit 1
263+ fi
264+
265+ TMP_DIR=$(mktemp -d)
266+ trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
267+ SOURCE_TARBALL="$TMP_DIR/source.tar.gz"
268+ SOURCE_URL="https://codeload.github.com/${REPO}/tar.gz/${COMMIT}"
269+ echo "Downloading source pinned to ${COMMIT}..."
270+ if ! curl -fsSL "$SOURCE_URL" -o "$SOURCE_TARBALL"; then
271+ echo "Error: Failed to download source for ${COMMIT}"
272+ exit 1
273+ fi
274+
275+ SOURCE_ROOT="gordon-${COMMIT}"
276+ if ! tar -tzf "$SOURCE_TARBALL" >"$TMP_DIR/archive.list"; then
277+ echo "Error: Invalid source archive"
278+ exit 1
279+ fi
280+ if [ ! -s "$TMP_DIR/archive.list" ] ||
281+ grep -Ev "^${SOURCE_ROOT}(/.*)?$" "$TMP_DIR/archive.list" >/dev/null ||
282+ grep -E '(^|/)\.\.?(/|$)' "$TMP_DIR/archive.list" >/dev/null ||
283+ tar -tvzf "$SOURCE_TARBALL" | grep -Ev '^[-d]' >/dev/null; then
284+ echo "Error: Source archive contains an unsafe path or entry type"
285+ exit 1
286+ fi
287+ tar -xzf "$SOURCE_TARBALL" -C "$TMP_DIR"
288+ SOURCE_DIR="$TMP_DIR/$SOURCE_ROOT"
289+ if [ ! -f "$SOURCE_DIR/go.mod" ] || [ ! -f "$SOURCE_DIR/main.go" ]; then
290+ echo "Error: Source archive is missing go.mod or the root command"
291+ exit 1
292+ fi
293+
294+ REQUIRED_GO=$(awk '$1 == "go" { print $2; exit }' "$SOURCE_DIR/go.mod")
295+ case "$REQUIRED_GO" in
296+ [0-9]*.[0-9]*) ;;
297+ *) echo "Error: Source go.mod has no valid Go version"; exit 1 ;;
298+ esac
299+ INSTALLED_GO=$(go env GOVERSION 2>/dev/null || true)
300+ INSTALLED_GO=${INSTALLED_GO#go}
301+ case "$INSTALLED_GO" in
302+ [0-9]*.[0-9]*) ;;
303+ *) echo "Error: Could not determine installed Go version"; exit 1 ;;
304+ esac
305+ if ! version_at_least "$INSTALLED_GO" "$REQUIRED_GO"; then
306+ echo "Error: Source requires Go ${REQUIRED_GO} or newer; found ${INSTALLED_GO}"
307+ exit 1
308+ fi
309+
310+ BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
311+ NEXT_VERSION="next-${COMMIT}"
312+ BINARY="$TMP_DIR/gordon"
313+ echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."
314+ if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \
315+ go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
316+ -o "$BINARY" .); then
317+ echo "Error: Failed to build Gordon from source"
318+ exit 1
319+ fi
320+ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
321+ echo "Error: Build did not produce a regular Gordon binary"
322+ exit 1
323+ fi
324+
325+ echo "Installing to ${INSTALL_DIR}..."
326+ install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
327+ echo "Installed unverified next build ${COMMIT}."
328+}
329+
330+case "$CHANNEL" in
331+ next)
332+ install_next
333+ post_install
334+ exit 0
335+ ;;
336+ stable|'') ;;
337+ *) echo "Error: Unsupported GORDON_CHANNEL: $CHANNEL"; exit 1 ;;
338+esac
339+
46340 # Construct download URL
47341 TARBALL="gordon_${OS}_${ARCH}.tar.gz"
48342
@@ -101,6 +395,8 @@ fi
101395
102396 # Verify checksum
103397 echo "Verifying checksum..."
398+# The sed expression is intentionally literal; shell expansion would corrupt it.
399+# shellcheck disable=SC2016
104400 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105401 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
106402 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
@@ -147,19 +443,6 @@ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
147443 fi
148444
149445 echo "Installing to ${INSTALL_DIR}..."
150-if [ -w "$INSTALL_DIR" ]; then
151- install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
152-else
153- echo "sudo required to install to ${INSTALL_DIR}"
154- sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
155-fi
446+install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
156447
157-# Verify installation
158-if command -v gordon >/dev/null 2>&1; then
159- echo ""
160- echo "Gordon installed successfully!"
161- gordon version
162-else
163- echo ""
164- echo "Installation complete. You may need to add ${INSTALL_DIR} to your PATH."
165-fi
448+post_install
A internal/adapters/dto/admin_apps.go +186 -0
@@ -0,0 +1,186 @@
1+package dto
2+
3+import "time"
4+
5+// App admin DTOs use stable wire-facing field names.
6+// No secret values exist in any shape here: secret references carry
7+// paths and env keys only. The versioned error envelope is a deliberate
8+// change from ErrorResponse for app mutations; clients accept both
9+// during the mixed-version window.
10+
11+// AppError is the v2.50 app-mutation error envelope. Its stable fields are
12+// error/message/cause/hint.
13+// `logs` never appears on mutations. Clients accept the legacy
14+// single-field ErrorResponse during the mixed-version window.
15+type AppError struct {
16+ Error string `json:"error"`
17+ Message string `json:"message,omitempty"`
18+ Cause string `json:"cause,omitempty"`
19+ Hint string `json:"hint,omitempty"`
20+}
21+
22+// AppDiffSection carries normalized added/removed/changed paths.
23+type AppDiffSection struct {
24+ Added []string `json:"added"`
25+ Removed []string `json:"removed"`
26+ Changed []string `json:"changed"`
27+}
28+
29+// AppApplyRequest asks the daemon to validate and persist desired state.
30+type AppApplyRequest struct {
31+ ManifestTOML string `json:"manifest_toml"`
32+ DryRun bool `json:"dry_run,omitempty"`
33+}
34+
35+// AppApplyResponse reports persistence success separately from deploy.
36+type AppApplyResponse struct {
37+ App string `json:"app"`
38+ FormerRevision string `json:"former_revision,omitempty"`
39+ ResultingRevision string `json:"resulting_revision,omitempty"`
40+ Pending bool `json:"pending"`
41+ Noop bool `json:"noop,omitempty"`
42+ Diff AppDiffSection `json:"diff"`
43+ Intent string `json:"intent,omitempty"`
44+}
45+
46+// AppDeployRequest activates a captured revision.
47+type AppDeployRequest struct {
48+ Revision string `json:"revision,omitempty"`
49+ Service string `json:"service,omitempty"`
50+}
51+
52+// AppServiceResultDTO is one service's terminal deployment result.
53+type AppServiceResultDTO struct {
54+ Result string `json:"result"`
55+ EffectiveRevision string `json:"effective_revision"`
56+ Before string `json:"before,omitempty"`
57+ After string `json:"after,omitempty"`
58+ RestartUnsafe bool `json:"restart_unsafe"`
59+ Error string `json:"error,omitempty"`
60+}
61+
62+// AppStepDTO is one journaled operation step.
63+type AppStepDTO struct {
64+ ID string `json:"id"`
65+ State string `json:"state"`
66+ Detail string `json:"detail,omitempty"`
67+ Error string `json:"error,omitempty"`
68+ Before string `json:"before,omitempty"`
69+ After string `json:"after,omitempty"`
70+ // Diagnostics is redacted failure output, populated only for callers
71+ // holding the logs read scope.
72+ Diagnostics []string `json:"diagnostics,omitempty"`
73+}
74+
75+// AppCleanupWarningDTO records post-publication leftovers.
76+type AppCleanupWarningDTO struct {
77+ Service string `json:"service"`
78+ Leftover string `json:"leftover"`
79+ Detail string `json:"detail"`
80+}
81+
82+// AppEffectiveDTO maps services to effective revisions.
83+type AppEffectiveDTO struct {
84+ Converged bool `json:"converged"`
85+ ConvergedRevision string `json:"converged_revision,omitempty"`
86+ Services map[string]string `json:"services"`
87+}
88+
89+// AppRetainedDTO lists the resources an app owns and would retain on
90+// removal (names and paths, never values).
91+type AppRetainedDTO struct {
92+ Volumes []string `json:"volumes"`
93+ Secrets []string `json:"secrets"`
94+ Images []string `json:"images"`
95+}
96+
97+// AppDeployResponse reports deploy outcome with terminal results.
98+// Effective and Retained are filled from current app state when the app
99+// still exists; they are omitted when it does not.
100+type AppDeployResponse struct {
101+ Op string `json:"op"`
102+ App string `json:"app"`
103+ Revision string `json:"revision"`
104+ Outcome string `json:"outcome"`
105+ Services map[string]AppServiceResultDTO `json:"services"`
106+ Steps []AppStepDTO `json:"steps"`
107+ CleanupWarnings []AppCleanupWarningDTO `json:"cleanup_warnings,omitempty"`
108+ Effective *AppEffectiveDTO `json:"effective,omitempty"`
109+ Retained *AppRetainedDTO `json:"retained,omitempty"`
110+}
111+
112+// AppDesiredDTO summarizes desired state.
113+type AppDesiredDTO struct {
114+ Revision string `json:"revision,omitempty"`
115+ Status string `json:"status,omitempty"`
116+ // Pending reports desired state ACTIVE has not reached yet.
117+ Pending bool `json:"pending"`
118+}
119+
120+// AppActiveServiceDTO is one effective service for inspection.
121+type AppActiveServiceDTO struct {
122+ EffectiveRevision string `json:"effective_revision"`
123+ Digest string `json:"digest,omitempty"`
124+ Container string `json:"container,omitempty"`
125+ RestartUnsafe bool `json:"restart_unsafe"`
126+}
127+
128+// AppActiveDTO carries per-service effective state.
129+type AppActiveDTO struct {
130+ Converged bool `json:"converged"`
131+ ConvergedRevision string `json:"converged_revision,omitempty"`
132+ Services map[string]AppActiveServiceDTO `json:"services"`
133+}
134+
135+// AppIntentDTO carries durable stopped/running intent.
136+type AppIntentDTO struct {
137+ Stopped bool `json:"stopped"`
138+}
139+
140+// AppLastOpDTO references the latest operation.
141+type AppLastOpDTO struct {
142+ Op string `json:"op"`
143+ Kind string `json:"kind,omitempty"`
144+ Outcome string `json:"outcome,omitempty"`
145+ StartedAt time.Time `json:"started_at,omitzero"`
146+}
147+
148+// AppShowResponse inspects desired + active + intent + ownership + op ref.
149+type AppShowResponse struct {
150+ App string `json:"app"`
151+ Desired AppDesiredDTO `json:"desired"`
152+ Active AppActiveDTO `json:"active"`
153+ Intent AppIntentDTO `json:"intent"`
154+ Retained AppRetainedDTO `json:"retained"`
155+ LastOp *AppLastOpDTO `json:"last_op,omitempty"`
156+}
157+
158+// AppSummaryDTO is one row of the app list.
159+type AppSummaryDTO struct {
160+ App string `json:"app"`
161+ Desired string `json:"desired,omitempty"`
162+ DesiredStatus string `json:"desired_status,omitempty"`
163+ Active string `json:"active,omitempty"`
164+ Converged bool `json:"converged"`
165+ Pending bool `json:"pending"`
166+ Stopped bool `json:"stopped"`
167+ LastOutcome string `json:"last_outcome,omitempty"`
168+}
169+
170+// AppDiffResponse reports normalized desired-vs-active diff.
171+type AppDiffResponse struct {
172+ App string `json:"app"`
173+ Diff AppDiffSection `json:"diff"`
174+}
175+
176+// AppSecretSetRequest writes secret values (names pre-registered).
177+type AppSecretSetRequest struct {
178+ Service string `json:"service"`
179+ Secrets map[string]string `json:"secrets"`
180+}
181+
182+// AppSecretDeleteRequest removes one secret value.
183+type AppSecretDeleteRequest struct {
184+ Service string `json:"service"`
185+ Key string `json:"key"`
186+}
A internal/adapters/dto/admin_apps_test.go +147 -0
@@ -0,0 +1,147 @@
1+package dto
2+
3+// Frozen-wire tests for the v2.50 app admin DTOs pin the exact JSON field
4+// names the daemon and the CLI exchange, and prove no response shape can
5+// carry secret values. The single intentional exception is
6+// AppSecretSetRequest: the write path must transport values.
7+
8+import (
9+ "encoding/json"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func dtoJSONKeys(t *testing.T, v any) map[string]any {
18+ t.Helper()
19+ raw, err := json.Marshal(v)
20+ require.NoError(t, err)
21+ var decoded map[string]any
22+ require.NoError(t, json.Unmarshal(raw, &decoded))
23+ return decoded
24+}
25+
26+func TestAppErrorEnvelopeKeys(t *testing.T) {
27+ envelope := AppError{Error: "secret-missing", Message: "db password missing", Cause: "pass lookup failed", Hint: "set it first"}
28+ keys := dtoJSONKeys(t, envelope)
29+ assert.Equal(t, "secret-missing", keys["error"])
30+ assert.Equal(t, "db password missing", keys["message"])
31+ assert.Equal(t, "pass lookup failed", keys["cause"])
32+ assert.Equal(t, "set it first", keys["hint"])
33+ assert.NotContains(t, keys, "logs", "mutations must never carry logs")
34+
35+ // Mixed-version window: the legacy single-field shape still decodes.
36+ var legacy AppError
37+ require.NoError(t, json.Unmarshal([]byte(`{"error":"image-unresolvable"}`), &legacy))
38+ assert.Equal(t, "image-unresolvable", legacy.Error)
39+ assert.Empty(t, legacy.Message)
40+}
41+
42+func TestAppApplyResponseKeys(t *testing.T) {
43+ resp := AppApplyResponse{
44+ App: "blog",
45+ FormerRevision: "rev-old",
46+ ResultingRevision: "rev-new",
47+ Pending: true,
48+ Diff: AppDiffSection{Added: []string{"service.web"}, Removed: []string{}, Changed: []string{}},
49+ Intent: "apply-abc",
50+ }
51+ keys := dtoJSONKeys(t, resp)
52+ for _, key := range []string{"app", "former_revision", "resulting_revision", "pending", "diff", "intent"} {
53+ assert.Contains(t, keys, key)
54+ }
55+ diff := keys["diff"].(map[string]any)
56+ for _, key := range []string{"added", "removed", "changed"} {
57+ assert.Contains(t, diff, key)
58+ }
59+}
60+
61+func TestAppDeployResponseKeys(t *testing.T) {
62+ resp := AppDeployResponse{
63+ Op: "op-abc",
64+ App: "blog",
65+ Revision: "rev-new",
66+ Outcome: "success",
67+ Services: map[string]AppServiceResultDTO{
68+ "web": {Result: "deployed", EffectiveRevision: "rev-new"},
69+ },
70+ Steps: []AppStepDTO{{ID: "service.web.replace", State: "succeeded"}},
71+ CleanupWarnings: []AppCleanupWarningDTO{
72+ {Service: "web", Leftover: "ctr-old", Detail: "retire failed after publish"},
73+ },
74+ Effective: &AppEffectiveDTO{Converged: true, Services: map[string]string{"web": "rev-new"}},
75+ Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
76+ }
77+ keys := dtoJSONKeys(t, resp)
78+ for _, key := range []string{"op", "app", "revision", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} {
79+ assert.Contains(t, keys, key)
80+ }
81+ svc := keys["services"].(map[string]any)["web"].(map[string]any)
82+ for _, key := range []string{"result", "effective_revision", "restart_unsafe"} {
83+ assert.Contains(t, svc, key)
84+ }
85+}
86+
87+func TestAppShowResponseKeys(t *testing.T) {
88+ resp := AppShowResponse{
89+ App: "blog",
90+ Desired: AppDesiredDTO{Revision: "rev-new", Status: "pending", Pending: true},
91+ Active: AppActiveDTO{Converged: false, Services: map[string]AppActiveServiceDTO{
92+ "web": {EffectiveRevision: "rev-old", Digest: "sha256:abc", Container: "ctr-old", RestartUnsafe: true},
93+ }},
94+ Intent: AppIntentDTO{Stopped: false},
95+ LastOp: &AppLastOpDTO{Op: "op-abc", Outcome: "success"},
96+ }
97+ keys := dtoJSONKeys(t, resp)
98+ for _, key := range []string{"app", "desired", "active", "intent", "retained", "last_op"} {
99+ assert.Contains(t, keys, key)
100+ }
101+ web := keys["active"].(map[string]any)["services"].(map[string]any)["web"].(map[string]any)
102+ for _, key := range []string{"effective_revision", "digest", "container", "restart_unsafe"} {
103+ assert.Contains(t, web, key)
104+ }
105+ assert.NotContains(t, web, "env", "active services must not inline env values")
106+}
107+
108+// TestAppResponsesCarryNoSecretValues marshals representative responses and
109+// proves a sentinel secret value appears nowhere. The request that carries
110+// values (AppSecretSetRequest) is asserted as the single exception.
111+func TestAppResponsesCarryNoSecretValues(t *testing.T) {
112+ const sentinel = "s3cr3t-value-sentinel"
113+
114+ responses := []any{
115+ AppApplyResponse{App: "blog", ResultingRevision: "rev-x", Intent: "apply-x"},
116+ AppDeployResponse{
117+ Op: "op-x", App: "blog", Revision: "rev-x", Outcome: "failed",
118+ Services: map[string]AppServiceResultDTO{
119+ "web": {Result: "failed", EffectiveRevision: "rev-x", Error: "boom"},
120+ },
121+ Effective: &AppEffectiveDTO{Services: map[string]string{"web": "rev-x"}},
122+ Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
123+ },
124+ AppShowResponse{
125+ App: "blog",
126+ Desired: AppDesiredDTO{Revision: "rev-x", Status: "active"},
127+ Active: AppActiveDTO{Services: map[string]AppActiveServiceDTO{
128+ "web": {EffectiveRevision: "rev-x", Digest: "sha256:x", Container: "ctr-x"},
129+ }},
130+ },
131+ AppSummaryDTO{App: "blog", Desired: "rev-x", Active: "rev-x"},
132+ AppDiffResponse{App: "blog", Diff: AppDiffSection{Changed: []string{"secret.db/password"}}},
133+ }
134+ for i, resp := range responses {
135+ raw, err := json.Marshal(resp)
136+ require.NoError(t, err, "response %d", i)
137+ body := string(raw)
138+ assert.NotContains(t, body, sentinel, "response %d leaks a secret value", i)
139+ assert.NotContains(t, strings.ToLower(body), `"value"`, "response %d has a value field", i)
140+ }
141+
142+ // The one intentional exception: the set-secret request transports values.
143+ setReq := AppSecretSetRequest{Service: "web", Secrets: map[string]string{"password": sentinel}}
144+ raw, err := json.Marshal(setReq)
145+ require.NoError(t, err)
146+ assert.Contains(t, string(raw), sentinel)
147+}
D internal/adapters/dto/admin_attachments.go +0 -29
@@ -1,29 +0,0 @@
1-// Package dto provides shared data transfer objects for API responses.
2-package dto
3-
4-// AttachmentsConfigResponse represents all configured attachments.
5-type AttachmentsConfigResponse struct {
6- Attachments map[string][]string `json:"attachments"`
7-}
8-
9-// AttachmentConfigResponse represents attachments for a specific target.
10-type AttachmentConfigResponse struct {
11- Target string `json:"target"`
12- Images []string `json:"images"`
13-}
14-
15-// AttachmentTargetsByImageResponse represents attachment targets for a specific image.
16-type AttachmentTargetsByImageResponse struct {
17- Image string `json:"image"`
18- Targets []string `json:"targets"`
19-}
20-
21-// AttachmentAddRequest represents a request to add an attachment.
22-type AttachmentAddRequest struct {
23- Image string `json:"image"`
24-}
25-
26-// AttachmentStatusResponse represents attachment operation result.
27-type AttachmentStatusResponse struct {
28- Status string `json:"status"`
29-}
D internal/adapters/dto/admin_autoroute.go +0 -16
@@ -1,16 +0,0 @@
1-package dto
2-
3-// AutoRouteAllowedDomainsResponse represents the list of allowed domains.
4-type AutoRouteAllowedDomainsResponse struct {
5- Domains []string `json:"domains"`
6-}
7-
8-// AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern.
9-type AutoRouteAllowedDomainRequest struct {
10- Pattern string `json:"pattern"`
11-}
12-
13-// AutoRouteStatusResponse represents a status response for auto-route operations.
14-type AutoRouteStatusResponse struct {
15- Status string `json:"status"`
16-}
M internal/adapters/dto/admin_backups.go +30 -39
@@ -2,11 +2,13 @@ package dto
22
33 import "time"
44
5-// BackupJob represents backup metadata in admin API responses.
5+// BackupJob represents backup metadata in admin API responses. App is the
6+// canonical backup identity: a domain is never used.
67 type BackupJob struct {
78 ID string `json:"id"`
8- Domain string `json:"domain"`
9- DBName string `json:"db_name"`
9+ App string `json:"app"`
10+ Service string `json:"service,omitempty"`
11+ Database string `json:"database,omitempty"`
1012 Schedule string `json:"schedule,omitempty"`
1113 Type string `json:"type"`
1214 Status string `json:"status"`
@@ -21,9 +23,12 @@ type BackupsResponse struct {
2123 Backups []BackupJob `json:"backups"`
2224 }
2325
24-// BackupRunRequest triggers a backup run.
26+// BackupRunRequest triggers one declared database backup. Service and
27+// Database are explicit selectors; an omitted selector only succeeds when
28+// exactly one compatible target exists.
2529 type BackupRunRequest struct {
26- DB string `json:"db,omitempty"`
30+ Service string `json:"service"`
31+ Database string `json:"database,omitempty"`
2732 }
2833
2934 // BackupRunResponse is returned after triggering a backup.
@@ -32,38 +37,23 @@ type BackupRunResponse struct {
3237 Backup *BackupJob `json:"backup,omitempty"`
3338 }
3439
35-// DatabaseInfo represents a detected database attachment.
36-type DatabaseInfo struct {
37- Type string `json:"type"`
38- Name string `json:"name"`
39- Version string `json:"version,omitempty"`
40- Host string `json:"host"`
41- Port int `json:"port"`
42- ContainerID string `json:"container_id"`
43- ImageName string `json:"image_name"`
44-}
45-
46-// BackupDetectResponse is returned by detect endpoints.
47-type BackupDetectResponse struct {
48- Databases []DatabaseInfo `json:"databases"`
49-}
50-
51-// VolumeBackupJob represents volume backup metadata in admin API responses.
40+// VolumeBackupJob represents volume backup metadata in admin API
41+// responses. App, service, and the declared volume name are the identity.
5242 type VolumeBackupJob struct {
53- ID string `json:"id"`
54- Domain string `json:"domain"`
55- ContainerName string `json:"container_name,omitempty"`
56- ContainerID string `json:"container_id,omitempty"`
57- VolumeName string `json:"volume_name"`
58- MountPath string `json:"mount_path,omitempty"`
59- Compression string `json:"compression,omitempty"`
60- Type string `json:"type"`
61- Status string `json:"status"`
62- StartedAt *time.Time `json:"started_at,omitempty"`
63- CompletedAt *time.Time `json:"completed_at,omitempty"`
64- SizeBytes int64 `json:"size_bytes"`
65- ArtifactRef string `json:"artifact_ref,omitempty"`
66- Error string `json:"error,omitempty"`
43+ ID string `json:"id"`
44+ App string `json:"app"`
45+ Service string `json:"service,omitempty"`
46+ VolumeName string `json:"volume"`
47+ RuntimeVolumeName string `json:"runtime_volume,omitempty"`
48+ MountPath string `json:"mount_path,omitempty"`
49+ Compression string `json:"compression,omitempty"`
50+ Type string `json:"type"`
51+ Status string `json:"status"`
52+ StartedAt *time.Time `json:"started_at,omitempty"`
53+ CompletedAt *time.Time `json:"completed_at,omitempty"`
54+ SizeBytes int64 `json:"size_bytes"`
55+ ArtifactRef string `json:"artifact_ref,omitempty"`
56+ Error string `json:"error,omitempty"`
6757 }
6858
6959 // VolumeBackupsResponse is returned by volume backup listing endpoints.
@@ -71,12 +61,13 @@ type VolumeBackupsResponse struct {
7161 Backups []VolumeBackupJob `json:"backups"`
7262 }
7363
74-// VolumeBackupRunRequest triggers volume backups.
64+// VolumeBackupRunRequest triggers one declared volume backup.
7565 type VolumeBackupRunRequest struct {
76- Volume string `json:"volume,omitempty"`
66+ Service string `json:"service"`
67+ Volume string `json:"volume,omitempty"`
7768 }
7869
79-// VolumeBackupRunResponse is returned after triggering volume backups.
70+// VolumeBackupRunResponse is returned after triggering a volume backup.
8071 type VolumeBackupRunResponse struct {
8172 Status string `json:"status"`
8273 Backups []VolumeBackupJob `json:"backups,omitempty"`
A internal/adapters/dto/admin_backups_test.go +37 -0
@@ -0,0 +1,37 @@
1+package dto
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestBackupDTOsUseAppIdentity proves the wire shape identifies a backup
12+// by app, service, and database or volume: a domain is never part of it.
13+func TestBackupDTOsUseAppIdentity(t *testing.T) {
14+ job := BackupJob{ID: "job-1", App: "shop", Service: "api", Database: "orders", Schedule: "daily", Status: "completed"}
15+ volume := VolumeBackupJob{ID: "job-2", App: "shop", Service: "api", VolumeName: "data", RuntimeVolumeName: "gordon-shop--api--vol--data", Status: "completed"}
16+
17+ for name, payload := range map[string]any{"database job": job, "volume job": volume} {
18+ t.Run(name, func(t *testing.T) {
19+ raw, err := json.Marshal(payload)
20+ require.NoError(t, err)
21+ var decoded map[string]any
22+ require.NoError(t, json.Unmarshal(raw, &decoded))
23+ for _, key := range []string{"id", "app", "service"} {
24+ assert.Contains(t, decoded, key)
25+ }
26+ assert.NotContains(t, decoded, "domain", "a domain is never a backup identity")
27+ })
28+ }
29+
30+ runRequest, err := json.Marshal(BackupRunRequest{Service: "api", Database: "orders"})
31+ require.NoError(t, err)
32+ assert.JSONEq(t, `{"service":"api","database":"orders"}`, string(runRequest))
33+
34+ volumeRequest, err := json.Marshal(VolumeBackupRunRequest{Service: "api", Volume: "data"})
35+ require.NoError(t, err)
36+ assert.JSONEq(t, `{"service":"api","volume":"data"}`, string(volumeRequest))
37+}
D internal/adapters/dto/admin_bootstrap.go +0 -25
@@ -1,25 +0,0 @@
1-package dto
2-
3-// BootstrapRequest represents a bootstrap operation request.
4-type BootstrapRequest struct {
5- Domain string `json:"domain"`
6- Image string `json:"image"`
7- Attachments []string `json:"attachments,omitempty"`
8- Env map[string]string `json:"env,omitempty"`
9- AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"`
10-}
11-
12-// BootstrapStep represents the result of one bootstrap step.
13-type BootstrapStep struct {
14- Name string `json:"name"`
15- Status string `json:"status"` // "created", "configured", "updated", "noop", "failed"
16-}
17-
18-// BootstrapResponse represents the result of a bootstrap operation.
19-type BootstrapResponse struct {
20- Domain string `json:"domain"`
21- Image string `json:"image"`
22- Steps []BootstrapStep `json:"steps"`
23- Warnings []string `json:"warnings,omitempty"`
24- Next string `json:"next"`
25-}
M internal/adapters/dto/admin_config.go +2 -7
@@ -1,12 +1,12 @@
11 package dto
22
33 // ConfigResponse represents server configuration.
4+// ConfigResponse represents installation configuration. App routes live
5+// under apps show, never here.
46 type ConfigResponse struct {
57 Server ServerConfig `json:"server"`
6- AutoRoute AutoRouteConfig `json:"auto_route"`
78 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
89 Volumes VolumesConfig `json:"volumes"`
9- Routes []Route `json:"routes"`
1010 ExternalRoutes []ExternalRoute `json:"external_routes"`
1111 }
1212
@@ -18,11 +18,6 @@ type ServerConfig struct {
1818 DataDir string `json:"data_dir,omitempty"`
1919 }
2020
21-// AutoRouteConfig represents auto-route config details.
22-type AutoRouteConfig struct {
23- Enabled bool `json:"enabled"`
24-}
25-
2621 // NetworkIsolationConfig represents network isolation settings.
2722 type NetworkIsolationConfig struct {
2823 Enabled bool `json:"enabled"`
D internal/adapters/dto/admin_deploy.go +0 -8
@@ -1,8 +0,0 @@
1-package dto
2-
3-// DeployResponse represents a deployment response.
4-type DeployResponse struct {
5- Status string `json:"status"`
6- ContainerID string `json:"container_id"`
7- Domain string `json:"domain"`
8-}
M internal/adapters/dto/admin_images.go +6 -2
@@ -17,11 +17,13 @@ type ImagesResponse struct {
1717 Images []Image `json:"images"`
1818 }
1919
20-// ImagePruneRequest triggers image pruning.
20+// ImagePruneRequest triggers image pruning. DryRun plans the full
21+// operation and deletes nothing.
2122 type ImagePruneRequest struct {
2223 KeepLast *int `json:"keep_last,omitempty"`
2324 PruneDangling *bool `json:"prune_dangling,omitempty"`
2425 PruneRegistry *bool `json:"prune_registry,omitempty"`
26+ DryRun *bool `json:"dry_run,omitempty"`
2527 }
2628
2729 // RuntimePruneResult represents runtime prune results.
@@ -37,8 +39,10 @@ type RegistryPruneResult struct {
3739 SpaceReclaimed int64 `json:"space_reclaimed"`
3840 }
3941
40-// ImagePruneResponse is returned by image prune endpoints.
42+// ImagePruneResponse is returned by image prune endpoints. Plan is the
43+// same shape for dry runs and executions.
4144 type ImagePruneResponse struct {
4245 Runtime RuntimePruneResult `json:"runtime"`
4346 Registry RegistryPruneResult `json:"registry"`
47+ Plan PruneSummary `json:"plan"`
4448 }
D internal/adapters/dto/admin_restart.go +0 -7
@@ -1,7 +0,0 @@
1-package dto
2-
3-// RestartResponse represents a restart response.
4-type RestartResponse struct {
5- Status string `json:"status"`
6- Domain string `json:"domain"`
7-}
M internal/adapters/dto/admin_secrets.go +2 -9
@@ -1,16 +1,9 @@
11 package dto
22
3-// AttachmentSecretsResponse represents secrets for an attachment container.
4-type AttachmentSecretsResponse struct {
5- Service string `json:"service"`
6- Keys []string `json:"keys"`
7-}
8-
93 // SecretsListResponse represents a list of secret keys for a domain.
104 type SecretsListResponse struct {
11- Domain string `json:"domain"`
12- Keys []string `json:"keys"`
13- Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"`
5+ Domain string `json:"domain"`
6+ Keys []string `json:"keys"`
147 }
158
169 // SecretsStatusResponse represents a secrets write response.
M internal/adapters/dto/admin_status.go +1 -2
@@ -2,11 +2,10 @@ package dto
22
33 // StatusResponse represents admin status information.
44 type StatusResponse struct {
5- Routes int `json:"routes"`
5+ Apps int `json:"apps"`
66 RegistryDomain string `json:"registry_domain"`
77 RegistryPort int `json:"registry_port"`
88 ServerPort int `json:"server_port"`
9- AutoRoute bool `json:"auto_route"`
109 NetworkIsolation bool `json:"network_isolation"`
1110 ContainerStatuses map[string]string `json:"container_status"`
1211 }
A internal/adapters/dto/prune.go +102 -0
@@ -0,0 +1,102 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// PruneCandidate is one planned prune candidate with its verdict and the
6+// stable reason codes behind it.
7+type PruneCandidate struct {
8+ Kind string `json:"kind"`
9+ Ref string `json:"ref"`
10+ Verdict string `json:"verdict"`
11+ Reasons []string `json:"reasons"`
12+}
13+
14+// PruneGap is one inventory that could not be read in full. A gap never
15+// fails the operation; it makes the dependent candidates unknown.
16+type PruneGap struct {
17+ Source string `json:"source"`
18+ Reason string `json:"reason"`
19+ Detail string `json:"detail,omitempty"`
20+}
21+
22+// PruneFailure is one deletion that failed without aborting the run.
23+type PruneFailure struct {
24+ Kind string `json:"kind"`
25+ Ref string `json:"ref"`
26+ Error string `json:"error"`
27+}
28+
29+// PruneSummary is the shared prune report shape: dry-run and execution
30+// return the same structure, distinguished by Applied.
31+type PruneSummary struct {
32+ // Applied is false for a dry run, where nothing was deleted.
33+ Applied bool `json:"applied"`
34+ // Eligible, Protected, and Unknown count the planned candidates.
35+ Eligible int `json:"eligible"`
36+ Protected int `json:"protected"`
37+ Unknown int `json:"unknown"`
38+ // Candidates lists every planned candidate with its verdict, so an
39+ // operator can see why each resource was kept or skipped.
40+ Candidates []PruneCandidate `json:"candidates"`
41+ // Deleted lists exactly the identities that were removed.
42+ Deleted []PruneCandidate `json:"deleted"`
43+ // Failures lists deletions that failed without aborting the run.
44+ Failures []PruneFailure `json:"failures,omitempty"`
45+ // Gaps lists every inventory that could not be read in full.
46+ Gaps []PruneGap `json:"gaps,omitempty"`
47+ // ReclaimedBytes is set only when the adapter can attribute exact
48+ // bytes to the deletions it performed.
49+ ReclaimedBytes int64 `json:"reclaimed_bytes"`
50+ ReclaimedKnown bool `json:"reclaimed_known"`
51+}
52+
53+// CountByKind counts the eligible candidates of one resource kind.
54+func (s PruneSummary) CountByKind(kind domain.PruneResourceKind) int {
55+ count := 0
56+ for _, candidate := range s.Candidates {
57+ if candidate.Kind == string(kind) && candidate.Verdict == string(domain.PruneVerdictEligible) {
58+ count++
59+ }
60+ }
61+ return count
62+}
63+
64+// PruneSummaryFromDomain maps the domain prune report to its wire form.
65+// Dry-run and execution share the shape, distinguished by Applied.
66+func PruneSummaryFromDomain(report domain.PruneReport) PruneSummary {
67+ summary := PruneSummary{
68+ Applied: report.Applied,
69+ Eligible: report.CountByVerdict(domain.PruneVerdictEligible),
70+ Protected: report.CountByVerdict(domain.PruneVerdictProtected),
71+ Unknown: report.CountByVerdict(domain.PruneVerdictUnknown),
72+ ReclaimedBytes: report.ReclaimedBytes,
73+ ReclaimedKnown: report.ReclaimedKnown,
74+ }
75+ for _, candidate := range report.Candidates {
76+ summary.Candidates = append(summary.Candidates, pruneCandidateFromDomain(candidate))
77+ }
78+ for _, candidate := range report.Deleted {
79+ summary.Deleted = append(summary.Deleted, pruneCandidateFromDomain(candidate))
80+ }
81+ for _, failure := range report.Failures {
82+ summary.Failures = append(summary.Failures, PruneFailure{
83+ Kind: string(failure.Kind), Ref: failure.Ref, Error: failure.Err,
84+ })
85+ }
86+ for _, gap := range report.Gaps {
87+ summary.Gaps = append(summary.Gaps, PruneGap{
88+ Source: string(gap.Source), Reason: string(gap.Reason), Detail: gap.Detail,
89+ })
90+ }
91+ return summary
92+}
93+
94+func pruneCandidateFromDomain(candidate domain.PruneCandidateReport) PruneCandidate {
95+ reasons := make([]string, 0, len(candidate.Reasons))
96+ for _, reason := range candidate.Reasons {
97+ reasons = append(reasons, string(reason))
98+ }
99+ return PruneCandidate{
100+ Kind: string(candidate.Kind), Ref: candidate.Ref, Verdict: string(candidate.Verdict), Reasons: reasons,
101+ }
102+}
M internal/adapters/dto/volumes.go +5 -3
@@ -20,8 +20,10 @@ type VolumePruneRequest struct {
2020 }
2121
2222 // VolumePruneResponse contains the results of a volume prune operation.
23+// Plan is the same shape for dry runs and executions.
2324 type VolumePruneResponse struct {
24- VolumesRemoved int `json:"volumes_removed"`
25- SpaceReclaimed int64 `json:"space_reclaimed"`
26- Volumes []Volume `json:"volumes,omitempty"`
25+ VolumesRemoved int `json:"volumes_removed"`
26+ SpaceReclaimed int64 `json:"space_reclaimed"`
27+ Volumes []Volume `json:"volumes,omitempty"`
28+ Plan PruneSummary `json:"plan"`
2729 }
A internal/adapters/in/appmanifest/parse.go +262 -0
@@ -0,0 +1,262 @@
1+// Package appmanifest decodes standalone app TOML files into
2+// normalized domain.AppSpec values. It performs SHAPE decoding only
3+// (strict unknown fields, type mapping, defaults); all semantic rules
4+// live in domain.AppSpec.Validate. It never reads files, environment,
5+// or secrets beyond the manifest bytes given to it.
6+package appmanifest
7+
8+import (
9+ "bytes"
10+ "fmt"
11+ "strings"
12+ "time"
13+
14+ "github.com/pelletier/go-toml/v2"
15+
16+ "github.com/bnema/gordon/internal/domain"
17+)
18+
19+// rawManifest mirrors the frozen TOML schema for strict decoding.
20+type rawManifest struct {
21+ Name string `toml:"name"`
22+ Env map[string]string `toml:"env"`
23+ Services []rawService `toml:"service"`
24+ Networks []rawSharedNetwork `toml:"network.shared"`
25+}
26+
27+// rawService mirrors one [[service]] table.
28+type rawService struct {
29+ Name string `toml:"name"`
30+ Image string `toml:"image"`
31+ Command []string `toml:"command"`
32+ StopGrace string `toml:"stop_grace"`
33+ Env map[string]string `toml:"env"`
34+ Readiness rawReadiness `toml:"readiness"`
35+ HTTP []rawHTTP `toml:"http"`
36+ TCP []rawTCP `toml:"tcp"`
37+ UDP []rawUDP `toml:"udp"`
38+ Secrets map[string]string `toml:"secrets"`
39+ Volumes []rawVolume `toml:"volume"`
40+ Databases []rawDatabase `toml:"database"`
41+ Backup rawBackup `toml:"backup"`
42+}
43+
44+// rawReadiness mirrors [service.readiness].
45+type rawReadiness struct {
46+ Type string `toml:"type"`
47+ Path string `toml:"path"`
48+ Contains string `toml:"contains"`
49+ Port int `toml:"port"`
50+ Timeout string `toml:"timeout"`
51+}
52+
53+// rawHTTP mirrors [[service.http]].
54+type rawHTTP struct {
55+ Host string `toml:"host"`
56+ Port int `toml:"port"`
57+ TLS string `toml:"tls"`
58+}
59+
60+// rawTCP mirrors [[service.tcp]].
61+type rawTCP struct {
62+ Entrypoint string `toml:"entrypoint"`
63+ Port int `toml:"port"`
64+ Publish string `toml:"publish"`
65+}
66+
67+// rawUDP mirrors [[service.udp]].
68+type rawUDP struct {
69+ Entrypoint string `toml:"entrypoint"`
70+ Port int `toml:"port"`
71+ Publish string `toml:"publish"`
72+}
73+
74+// rawVolume mirrors [[service.volume]].
75+type rawVolume struct {
76+ Name string `toml:"name"`
77+ Path string `toml:"path"`
78+ ReadOnly bool `toml:"readonly"`
79+}
80+
81+// rawDatabase mirrors [[service.database]].
82+type rawDatabase struct {
83+ Name string `toml:"name"`
84+ Type string `toml:"type"`
85+ Schedule string `toml:"schedule"`
86+}
87+
88+// rawBackup mirrors [service.backup].
89+type rawBackup struct {
90+ Postgres []string `toml:"postgres"`
91+ Volume []string `toml:"volume"`
92+}
93+
94+// rawSharedNetwork mirrors [[network.shared]].
95+type rawSharedNetwork struct {
96+ Network string `toml:"network"`
97+ Services []string `toml:"services"`
98+ Aliases []string `toml:"aliases"`
99+}
100+
101+// Parse decodes manifest TOML bytes into a validated, normalized AppSpec.
102+// sourceName is used for mismatch warnings only; the name field is authoritative.
103+func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
104+ var raw rawManifest
105+ decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
106+ if err := decoder.Decode(&raw); err != nil {
107+ return domain.AppSpec{}, nil, fmt.Errorf("%w: %v", domain.ErrInvalidAppSpec, err)
108+ }
109+ spec, err := toDomain(raw)
110+ if err != nil {
111+ return domain.AppSpec{}, nil, err
112+ }
113+ var warnings []string
114+ if sourceName != "" && sourceName != spec.Name && sourceName != spec.Name+".toml" {
115+ warnings = append(warnings, fmt.Sprintf("file name %q does not match app name %q", sourceName, spec.Name))
116+ }
117+ if err := spec.Validate(); err != nil {
118+ return domain.AppSpec{}, warnings, err
119+ }
120+ return spec, warnings, nil
121+}
122+
123+// toDomain maps raw TOML onto domain types with normalization and defaults.
124+func toDomain(raw rawManifest) (domain.AppSpec, error) {
125+ spec := domain.AppSpec{
126+ Name: raw.Name,
127+ Env: map[string]string{},
128+ Services: make([]domain.AppService, 0, len(raw.Services)),
129+ Networks: make([]domain.AppSharedNetwork, 0, len(raw.Networks)),
130+ }
131+ for key, value := range raw.Env {
132+ spec.Env[key] = value
133+ }
134+ for i := range raw.Services {
135+ svc, err := toDomainService(raw.Services[i])
136+ if err != nil {
137+ return domain.AppSpec{}, err
138+ }
139+ spec.Services = append(spec.Services, svc)
140+ }
141+ for _, net := range raw.Networks {
142+ spec.Networks = append(spec.Networks, domain.AppSharedNetwork{
143+ Network: net.Network,
144+ Services: append([]string(nil), net.Services...),
145+ Aliases: append([]string(nil), net.Aliases...),
146+ })
147+ }
148+ return spec, nil
149+}
150+
151+// toDomainService maps one raw service with defaults.
152+func toDomainService(raw rawService) (domain.AppService, error) {
153+ svc := domain.AppService{
154+ Name: raw.Name,
155+ Image: normalizeImage(raw.Image),
156+ Command: append([]string(nil), raw.Command...),
157+ Secrets: map[string]string{},
158+ Backup: domain.AppBackup{
159+ Postgres: append([]string(nil), raw.Backup.Postgres...),
160+ Volume: append([]string(nil), raw.Backup.Volume...),
161+ },
162+ }
163+ stopGrace := domain.AppDefaultStopGrace
164+ if raw.StopGrace != "" {
165+ parsed, err := time.ParseDuration(raw.StopGrace)
166+ if err != nil {
167+ return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, raw.Name, raw.StopGrace, err)
168+ }
169+ stopGrace = parsed
170+ }
171+ svc.StopGrace = stopGrace
172+ readiness, err := toDomainReadiness(raw.Name, raw.Readiness)
173+ if err != nil {
174+ return domain.AppService{}, err
175+ }
176+ svc.Readiness = readiness
177+ for _, h := range raw.HTTP {
178+ tls := h.TLS
179+ if tls == "" {
180+ tls = domain.AppTLSAuto
181+ }
182+ svc.HTTP = append(svc.HTTP, domain.AppHTTPInterface{
183+ Host: domain.CanonicalHTTPHost(h.Host),
184+ Port: h.Port,
185+ TLS: tls,
186+ })
187+ }
188+ for _, t := range raw.TCP {
189+ svc.TCP = append(svc.TCP, domain.AppTCPInterface{
190+ Entrypoint: t.Entrypoint,
191+ Port: t.Port,
192+ Publish: t.Publish,
193+ })
194+ }
195+ for _, u := range raw.UDP {
196+ svc.UDP = append(svc.UDP, domain.AppUDPInterface{
197+ Entrypoint: u.Entrypoint,
198+ Port: u.Port,
199+ Publish: u.Publish,
200+ })
201+ }
202+ for key, value := range raw.Secrets {
203+ svc.Secrets[key] = value
204+ }
205+ for _, v := range raw.Volumes {
206+ svc.Volumes = append(svc.Volumes, domain.AppVolume{
207+ Name: v.Name,
208+ Path: v.Path,
209+ ReadOnly: v.ReadOnly,
210+ })
211+ }
212+ for _, db := range raw.Databases {
213+ svc.Databases = append(svc.Databases, domain.AppDatabase{
214+ Name: db.Name,
215+ Type: db.Type,
216+ Schedule: db.Schedule,
217+ })
218+ }
219+ if len(raw.Env) > 0 {
220+ return domain.AppService{}, fmt.Errorf("%w: service %q [service.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, raw.Name)
221+ }
222+ return svc, nil
223+}
224+
225+// toDomainReadiness maps readiness with defaults.
226+func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
227+ readiness := domain.AppReadiness{
228+ Type: raw.Type,
229+ Path: raw.Path,
230+ Contains: raw.Contains,
231+ Port: raw.Port,
232+ Timeout: domain.AppDefaultReadinessTimeout,
233+ }
234+ if readiness.Type == "" {
235+ readiness.Type = domain.AppReadinessNone
236+ }
237+ if raw.Timeout != "" {
238+ parsed, err := time.ParseDuration(raw.Timeout)
239+ if err != nil {
240+ return domain.AppReadiness{}, fmt.Errorf("%w: service %q readiness timeout %q is invalid: %v", domain.ErrInvalidAppSpec, service, raw.Timeout, err)
241+ }
242+ readiness.Timeout = parsed
243+ }
244+ return readiness, nil
245+}
246+
247+// normalizeImage records a missing tag as explicit :latest.
248+func normalizeImage(image string) string {
249+ image = strings.TrimSpace(image)
250+ if image == "" {
251+ return image
252+ }
253+ if strings.Contains(image, "@") {
254+ return image
255+ }
256+ lastSlash := strings.LastIndex(image, "/")
257+ lastColon := strings.LastIndex(image, ":")
258+ if lastColon <= lastSlash {
259+ return image + ":latest"
260+ }
261+ return image
262+}
A internal/adapters/in/appmanifest/parse_test.go +254 -0
@@ -0,0 +1,254 @@
1+package appmanifest_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/appmanifest"
11+)
12+
13+const validWeb = `
14+name = "blog"
15+
16+[env]
17+APP_ENV = "production"
18+
19+[[service]]
20+name = "web"
21+image = "registry.example.com/blog/web:1.4.2"
22+
23+[service.readiness]
24+type = "http"
25+path = "/healthz"
26+
27+[[service.http]]
28+host = "Blog.Example.COM."
29+port = 8080
30+
31+[service.secrets]
32+DATABASE_URL = "database-url"
33+`
34+
35+func TestParse_ValidWeb(t *testing.T) {
36+ spec, warnings, err := appmanifest.Parse([]byte(validWeb), "blog.toml")
37+ require.NoError(t, err)
38+ assert.Empty(t, warnings)
39+ assert.Equal(t, "blog", spec.Name)
40+ assert.Equal(t, "production", spec.Env["APP_ENV"])
41+ require.Len(t, spec.Services, 1)
42+ svc := spec.Services[0]
43+ assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image)
44+ assert.Equal(t, "blog.example.com", svc.HTTP[0].Host)
45+ assert.Equal(t, "auto", svc.HTTP[0].TLS)
46+ assert.Equal(t, "http", svc.Readiness.Type)
47+}
48+
49+func TestParse_FileNameMismatchIsWarning(t *testing.T) {
50+ _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")
51+ require.NoError(t, err)
52+ require.Len(t, warnings, 1)
53+ assert.Contains(t, warnings[0], "other.toml")
54+}
55+
56+func TestParse_MissingTagNormalizesToLatest(t *testing.T) {
57+ doc := `
58+name = "blog"
59+[[service]]
60+name = "web"
61+image = "registry.example.com/blog/web"
62+[[service.http]]
63+host = "blog.example.com"
64+port = 8080
65+`
66+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
67+ require.NoError(t, err)
68+ assert.Equal(t, "registry.example.com/blog/web:latest", spec.Services[0].Image)
69+}
70+
71+func TestParse_UnknownFieldsRejected(t *testing.T) {
72+ cases := []struct {
73+ name string
74+ doc string
75+ }{
76+ {"top level", "name = \"blog\"\nbogus = 1\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n"},
77+ {"service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nbogus = 1\n"},
78+ {"readiness", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.readiness]\nbogus = 1\n"},
79+ {"http", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"},
80+ }
81+ for _, tc := range cases {
82+ t.Run(tc.name, func(t *testing.T) {
83+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
84+ require.Error(t, err)
85+ })
86+ }
87+}
88+
89+func TestParse_ServiceEnvRejected(t *testing.T) {
90+ doc := `
91+name = "blog"
92+[[service]]
93+name = "web"
94+image = "img:1"
95+[service.env]
96+FOO = "bar"
97+`
98+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
99+ require.ErrorContains(t, err, "[service.env]")
100+}
101+
102+func TestParse_Names(t *testing.T) {
103+ cases := []struct {
104+ name string
105+ doc string
106+ wantErr string
107+ }{
108+ {"bad app", "name = \"Blog!\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "app name"},
109+ {"double dash app", "name = \"a--b\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "--"},
110+ {"reserved app", "name = \"gordon\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "reserved"},
111+ {"no services", "name = \"blog\"\n", "at least one service"},
112+ {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"},
113+ {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"},
114+ {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"},
115+ {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "missing in the target struct"},
116+ {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
117+ {"shared volume", "name = \"blog\"\n[[service]]\nname = \"a\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n[[service]]\nname = \"b\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"},
118+ {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"},
119+ {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "secret references"},
120+ }
121+ for _, tc := range cases {
122+ t.Run(tc.name, func(t *testing.T) {
123+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
124+ require.ErrorContains(t, err, tc.wantErr)
125+ })
126+ }
127+}
128+
129+func TestParse_Readiness(t *testing.T) {
130+ cases := []struct {
131+ name string
132+ doc string
133+ wantErr string
134+ }{
135+ {"bad type", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"},
136+ {"log needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"},
137+ {"http needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[service.readiness]\ntype = \"http\"\n", "http readiness requires path"},
138+ {"udp tcp rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[service.readiness]\ntype = \"tcp\"\n", "UDP-only"},
139+ {"multi tcp needs port", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[[service.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[service.readiness]\ntype = \"tcp\"\n", "readiness.port is required"},
140+ {"bad port ref", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[service.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"},
141+ {"bad timeout", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntimeout = \"99h\"\n", "timeout must be within"},
142+ }
143+ for _, tc := range cases {
144+ t.Run(tc.name, func(t *testing.T) {
145+ _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
146+ require.ErrorContains(t, err, tc.wantErr)
147+ })
148+ }
149+}
150+
151+func TestParse_Interfaces(t *testing.T) {
152+ cases := []struct {
153+ name string
154+ doc string
155+ wantErr string
156+ }{
157+ {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
158+ {"publish hostname", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"},
159+ {"rcon table rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "missing in the target struct"},
160+ {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
161+ }
162+ for _, tc := range cases {
163+ t.Run(tc.name, func(t *testing.T) {
164+ _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
165+ require.ErrorContains(t, err, tc.wantErr)
166+ })
167+ }
168+}
169+
170+func TestParse_Backups(t *testing.T) {
171+ doc := `
172+name = "shop"
173+[[service]]
174+name = "api"
175+image = "registry.example.com/shop/api:2.0.0"
176+[[service.http]]
177+host = "shop.example.com"
178+port = 8080
179+[[service.database]]
180+name = "main"
181+type = "postgres"
182+schedule = "daily"
183+[service.backup]
184+postgres = ["main"]
185+`
186+ spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml")
187+ require.NoError(t, err)
188+ assert.Equal(t, "daily", spec.Services[0].Databases[0].Schedule)
189+
190+ badRef := strings.Replace(doc, "postgres = [\"main\"]", "postgres = [\"ghost\"]", 1)
191+ _, _, err = appmanifest.Parse([]byte(badRef), "shop.toml")
192+ require.ErrorContains(t, err, "unknown database")
193+
194+ badSchedule := strings.Replace(doc, "schedule = \"daily\"", "schedule = \"minutely\"", 1)
195+ _, _, err = appmanifest.Parse([]byte(badSchedule), "shop.toml")
196+ require.ErrorContains(t, err, "schedule must be")
197+
198+ badEngine := strings.Replace(doc, "type = \"postgres\"", "type = \"mysql\"", 1)
199+ _, _, err = appmanifest.Parse([]byte(badEngine), "shop.toml")
200+ require.ErrorContains(t, err, "must be postgres")
201+}
202+
203+func TestParse_GameAndStagingExamples(t *testing.T) {
204+ game := `
205+name = "rust"
206+[[service]]
207+name = "server"
208+image = "registry.example.com/games/rust:2026.09"
209+[service.readiness]
210+type = "log"
211+path = "/data/logs/server.log"
212+contains = "Server startup complete"
213+timeout = "5m"
214+[[service.udp]]
215+entrypoint = "udp"
216+port = 28015
217+publish = "0.0.0.0:28015"
218+[[service.tcp]]
219+entrypoint = "tcp"
220+port = 28016
221+publish = "127.0.0.1:28016"
222+[[service.volume]]
223+name = "rust-data"
224+path = "/data"
225+`
226+ spec, _, err := appmanifest.Parse([]byte(game), "rust.toml")
227+ require.NoError(t, err)
228+ assert.Equal(t, "log", spec.Services[0].Readiness.Type)
229+ require.Len(t, spec.Services[0].TCP, 1)
230+ assert.Equal(t, 28016, spec.Services[0].TCP[0].Port)
231+
232+ staging := `
233+name = "blog-staging"
234+[env]
235+APP_ENV = "staging"
236+[[service]]
237+name = "web"
238+image = "registry.example.com/blog/web:1.4.2-rc.1"
239+[[service.http]]
240+host = "staging-blog.example.com"
241+port = 8080
242+`
243+ spec, _, err = appmanifest.Parse([]byte(staging), "blog-staging.toml")
244+ require.NoError(t, err)
245+ assert.Equal(t, "blog-staging", spec.Name)
246+}
247+
248+func TestParse_NoFileEnvSecretReads(t *testing.T) {
249+ // Parser input is bytes only; there is no path parameter that could
250+ // trigger file, env, or secret reads beyond the manifest input.
251+ spec, _, err := appmanifest.Parse([]byte(validWeb), "")
252+ require.NoError(t, err)
253+ assert.Equal(t, "blog", spec.Name)
254+}
A internal/adapters/in/cli/apps.go +1014 -0
@@ -0,0 +1,1014 @@
1+package cli
2+
3+// App CLI surface for declarative app operations.
4+//
5+// Conventions (per AGENTS.md): cmd.Context(), cmd.OutOrStdout(), errors
6+// from RunE, --json via writeJSON with equivalent semantics, stable
7+// sorted ordering, and NO secret values or sensitive output anywhere.
8+
9+import (
10+ "context"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "os"
15+ "sort"
16+ "strings"
17+
18+ "github.com/spf13/cobra"
19+
20+ "github.com/bnema/gordon/internal/adapters/dto"
21+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
22+)
23+
24+// appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).
25+const appApplyMaxBytes = 1 << 20
26+
27+// newLocalAppClient constructs the owner-only local daemon client. It is a
28+// variable so command tests can inject a socket-backed or unavailable client.
29+var newLocalAppClient = remote.NewLocalClient
30+
31+// resolveAppClient returns the daemon client for app operations. An explicit
32+// remote (flag or GORDON_REMOTE) is authoritative and never falls back to the
33+// local socket; otherwise the owner-only local admin socket is used.
34+func resolveAppClient() (*remote.Client, error) {
35+ client, isRemote, err := GetRemoteClient()
36+ if err != nil {
37+ return nil, err
38+ }
39+ if isRemote {
40+ return client, nil
41+ }
42+
43+ localClient, err := newLocalAppClient()
44+ if err != nil {
45+ return nil, fmt.Errorf(
46+ "daemon-unavailable: no daemon endpoint is reachable; app operations "+
47+ "are daemon-owned and have no local-write fallback "+
48+ "(start the daemon or pass --remote): %w", err)
49+ }
50+ return localClient, nil
51+}
52+
53+// resolveAppPlane returns the daemon-backed control plane for app commands.
54+// App operations are daemon-owned for BOTH local and remote paths
55+// (05-api-cli.md §1): the explicit remote when one is selected, otherwise
56+// the owner-only local admin socket. When neither is reachable the command
57+// fails with daemon-unavailable — there is no local-write fallback. The
58+// returned handle owns the client and must be closed by the caller.
59+func resolveAppPlane() (*controlPlaneHandle, error) {
60+ client, err := resolveAppClient()
61+ if err != nil {
62+ return nil, err
63+ }
64+ return &controlPlaneHandle{plane: NewRemoteControlPlane(client)}, nil
65+}
66+
67+// appMutationError translates ambiguous transport outcomes into the
68+// outcome-unknown guidance: never blindly retry, re-query by key first.
69+func appMutationError(op, app, key string, err error) error {
70+ var unknown *remote.OutcomeUnknownError
71+ if errors.As(err, &unknown) {
72+ return fmt.Errorf(
73+ "outcome-unknown: %s %s may have executed; query "+
74+ "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",
75+ op, app, app, key, err)
76+ }
77+ return err
78+}
79+
80+// renderAppOpConflict renders the journaled 409 deploy/lifecycle response
81+// carried by remote.AppOpConflictError, then returns the terminal failure
82+// (nonzero exit) with the operation key and by-key recovery guidance. It
83+// reports false when err is not a journaled conflict so callers fall
84+// through to appMutationError.
85+func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut bool) (error, bool) {
86+ var conflict *remote.AppOpConflictError
87+ if !errors.As(err, &conflict) {
88+ return nil, false
89+ }
90+ if jsonOut {
91+ if werr := writeJSON(out, conflict.Response); werr != nil {
92+ return werr, true
93+ }
94+ } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {
95+ return rerr, true
96+ }
97+ return fmt.Errorf(
98+ "%s of %s did not succeed (outcome %s, op %s); journal rendered above; query "+
99+ "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",
100+ op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true
101+}
102+
103+// newAppsCmd creates the `apps` parent command.
104+func newAppsCmd() *cobra.Command {
105+ cmd := &cobra.Command{
106+ Use: "apps",
107+ Short: "Manage applications (daemon-owned)",
108+ Long: `Validate, persist, inspect, and operate applications.
109+
110+All mutations are executed by the daemon through the admin API;
111+without a reachable daemon the commands fail instead of writing locally.
112+
113+Examples:
114+ gordon apps apply --file blog.toml
115+ gordon apps list
116+ gordon apps show blog
117+ gordon apps diff blog`,
118+ }
119+ cmd.AddCommand(
120+ newAppsApplyCmd(),
121+ newAppsListCmd(),
122+ newAppsShowCmd(),
123+ newAppsDiffCmd(),
124+ newAppsSecretsCmd(),
125+ newAppDeployCmd(),
126+ newAppRestartCmd(),
127+ newAppStopCmd(),
128+ newAppStartCmd(),
129+ newAppRemoveCmd(),
130+ newAppStatusCmd(),
131+ newAppLogsCmd(),
132+ )
133+ return cmd
134+}
135+
136+// newAppsApplyCmd creates `apps apply`.
137+func newAppsApplyCmd() *cobra.Command {
138+ var file string
139+ var dryRun bool
140+ var chainDeploy bool
141+ var jsonOut bool
142+ cmd := &cobra.Command{
143+ Use: "apply",
144+ Short: "Validate and persist an app manifest",
145+ Long: `Validates a manifest file and persists it as desired state (or dry-runs).
146+
147+With --deploy, chains exactly the accepted revision into a deploy after
148+persistence succeeds; the two outcomes are reported separately because a
149+deploy may fail after the apply succeeded.`,
150+ RunE: func(cmd *cobra.Command, args []string) error {
151+ handle, err := resolveAppPlane()
152+ if err != nil {
153+ return err
154+ }
155+ defer handle.close()
156+ plane := handle.plane
157+ return runAppsApply(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), file, dryRun, chainDeploy, jsonOut)
158+ },
159+ }
160+ cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")
161+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate without persisting")
162+ cmd.Flags().BoolVar(&chainDeploy, "deploy", false, "Deploy the accepted revision after applying")
163+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
164+ return cmd
165+}
166+
167+func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error {
168+ if dryRun && chainDeploy {
169+ return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")
170+ }
171+ if file == "" {
172+ return fmt.Errorf("missing required flag --file")
173+ }
174+ data, err := os.ReadFile(file)
175+ if err != nil {
176+ return fmt.Errorf("failed to read manifest file: %w", err)
177+ }
178+ if len(data) > appApplyMaxBytes {
179+ return fmt.Errorf("manifest exceeds the 1 MiB apply bound (%d bytes)", len(data))
180+ }
181+ resp, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: string(data), DryRun: dryRun})
182+ if err != nil {
183+ return err
184+ }
185+ if !chainDeploy {
186+ if jsonOut {
187+ return writeJSON(out, resp)
188+ }
189+ return renderAppApply(out, resp, dryRun)
190+ }
191+ if !jsonOut {
192+ if err := renderAppApply(out, resp, dryRun); err != nil {
193+ return err
194+ }
195+ }
196+ deployResp, key, deployErr := plane.DeployApp(ctx, resp.App, dto.AppDeployRequest{Revision: resp.ResultingRevision})
197+ if deployErr != nil {
198+ if conflictErr, ok := renderAppOpConflict(out, "deploy", resp.App, key, deployErr, jsonOut); ok {
199+ return fmt.Errorf("apply of %s succeeded (%s); %w",
200+ resp.App, resp.ResultingRevision, conflictErr)
201+ }
202+ return fmt.Errorf("apply of %s succeeded (%s); %w",
203+ resp.App, resp.ResultingRevision, appMutationError("deploy", resp.App, key, deployErr))
204+ }
205+ if jsonOut {
206+ return writeJSON(out, struct {
207+ Apply *dto.AppApplyResponse `json:"apply"`
208+ Deploy *dto.AppDeployResponse `json:"deploy"`
209+ }{Apply: resp, Deploy: deployResp})
210+ }
211+ return renderAppDeployResponse(out, deployResp)
212+}
213+
214+func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {
215+ if resp.Noop {
216+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))
217+ }
218+ verb := "Applied"
219+ if dryRun {
220+ verb = "Validated (dry-run)"
221+ }
222+ if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("%s %s%s", verb, resp.App, appRevisionSuffix(resp.ResultingRevision)))); err != nil {
223+ return err
224+ }
225+ if err := renderAppDiffSection(out, resp.Diff); err != nil {
226+ return err
227+ }
228+ if resp.Pending {
229+ return cliWriteLine(out, cliRenderMeta("intent:", resp.Intent))
230+ }
231+ return nil
232+}
233+
234+// appRevisionSuffix omits the parenthesized revision when empty (dry-run
235+// validation may not produce a revision).
236+func appRevisionSuffix(revision string) string {
237+ if revision == "" {
238+ return ""
239+ }
240+ return " (" + revision + ")"
241+}
242+
243+// renderAppDiffSection renders normalized diff paths using the same
244+// added/removed/changed sections as `apps diff`; empty diffs render nothing.
245+func renderAppDiffSection(out io.Writer, diff dto.AppDiffSection) error {
246+ if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0 {
247+ return nil
248+ }
249+ for _, section := range []struct {
250+ title string
251+ paths []string
252+ }{
253+ {"added:", diff.Added},
254+ {"removed:", diff.Removed},
255+ {"changed:", diff.Changed},
256+ } {
257+ paths := append([]string(nil), section.paths...)
258+ sort.Strings(paths)
259+ for _, p := range paths {
260+ if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
261+ return err
262+ }
263+ }
264+ }
265+ return nil
266+}
267+
268+// newAppsListCmd creates `apps list`.
269+func newAppsListCmd() *cobra.Command {
270+ var jsonOut bool
271+ cmd := &cobra.Command{
272+ Use: "list",
273+ Short: "List applications",
274+ RunE: func(cmd *cobra.Command, args []string) error {
275+ handle, err := resolveAppPlane()
276+ if err != nil {
277+ return err
278+ }
279+ defer handle.close()
280+ plane := handle.plane
281+ return runAppsList(cmd.Context(), plane, cmd.OutOrStdout(), jsonOut)
282+ },
283+ }
284+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
285+ return cmd
286+}
287+
288+func runAppsList(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
289+ apps, err := plane.ListApps(ctx)
290+ if err != nil {
291+ return err
292+ }
293+ if jsonOut {
294+ if apps == nil {
295+ apps = []dto.AppSummaryDTO{}
296+ }
297+ return writeJSON(out, apps)
298+ }
299+ if len(apps) == 0 {
300+ return cliWriteLine(out, cliRenderEmptyState("No apps."))
301+ }
302+ names := make([]string, 0, len(apps))
303+ byName := make(map[string]dto.AppSummaryDTO, len(apps))
304+ for _, a := range apps {
305+ names = append(names, a.App)
306+ byName[a.App] = a
307+ }
308+ sort.Strings(names)
309+ for _, name := range names {
310+ a := byName[name]
311+ state := "active"
312+ switch {
313+ case a.Stopped:
314+ state = "stopped"
315+ case a.Pending:
316+ state = "pending"
317+ case a.Active == "":
318+ state = "applied"
319+ case a.LastOutcome != "" && a.LastOutcome != "success":
320+ state += " (" + a.LastOutcome + ")"
321+ }
322+ if err := cliWriteLine(out, cliRenderListItem(fmt.Sprintf("%s (%s)", name, state))); err != nil {
323+ return err
324+ }
325+ }
326+ return nil
327+}
328+
329+// newAppsShowCmd creates `apps show`.
330+func newAppsShowCmd() *cobra.Command {
331+ var jsonOut bool
332+ cmd := &cobra.Command{
333+ Use: "show APP",
334+ Short: "Show desired and active state for an app",
335+ Args: cobra.ExactArgs(1),
336+ RunE: func(cmd *cobra.Command, args []string) error {
337+ handle, err := resolveAppPlane()
338+ if err != nil {
339+ return err
340+ }
341+ defer handle.close()
342+ plane := handle.plane
343+ return runAppsShow(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
344+ },
345+ }
346+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
347+ return cmd
348+}
349+
350+func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
351+ resp, err := plane.ShowApp(ctx, app)
352+ if err != nil {
353+ return err
354+ }
355+ if jsonOut {
356+ return writeJSON(out, resp)
357+ }
358+ if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
359+ return err
360+ }
361+ if err := cliWriteLine(out, cliRenderMeta("desired:", resp.Desired.Revision+" ("+resp.Desired.Status+")")); err != nil {
362+ return err
363+ }
364+ for _, line := range renderActiveServices(resp.Active) {
365+ if err := cliWriteLine(out, line); err != nil {
366+ return err
367+ }
368+ }
369+ if resp.Intent.Stopped {
370+ if err := cliWriteLine(out, cliRenderWarning("stopped intent is set")); err != nil {
371+ return err
372+ }
373+ }
374+ if resp.LastOp != nil && resp.LastOp.Op != "" {
375+ if err := cliWriteLine(out, cliRenderMeta("last-op:", resp.LastOp.Op+" ("+resp.LastOp.Outcome+")")); err != nil {
376+ return err
377+ }
378+ }
379+ owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
380+ owned = append(owned, resp.Retained.Secrets...)
381+ if len(owned) > 0 {
382+ sort.Strings(owned)
383+ return cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " ")))
384+ }
385+ return nil
386+}
387+
388+// renderActiveServices renders per-service effective state, sorted by name.
389+// Only ids and digests appear — never secret values.
390+func renderActiveServices(active dto.AppActiveDTO) []string {
391+ names := make([]string, 0, len(active.Services))
392+ for name := range active.Services {
393+ names = append(names, name)
394+ }
395+ sort.Strings(names)
396+ lines := make([]string, 0, len(names)+1)
397+ converged := "converged"
398+ if !active.Converged {
399+ converged = "diverged"
400+ }
401+ lines = append(lines, cliRenderMeta("active:", converged))
402+ for _, name := range names {
403+ svc := active.Services[name]
404+ detail := svc.EffectiveRevision
405+ if svc.Container != "" {
406+ detail += " container=" + svc.Container
407+ }
408+ if svc.RestartUnsafe {
409+ detail += " restart_unsafe"
410+ }
411+ lines = append(lines, cliRenderMeta(" "+name+":", detail))
412+ }
413+ return lines
414+}
415+
416+// newAppsDiffCmd creates `apps diff`.
417+func newAppsDiffCmd() *cobra.Command {
418+ var jsonOut bool
419+ cmd := &cobra.Command{
420+ Use: "diff APP",
421+ Short: "Show the normalized desired-vs-active diff",
422+ Args: cobra.ExactArgs(1),
423+ RunE: func(cmd *cobra.Command, args []string) error {
424+ handle, err := resolveAppPlane()
425+ if err != nil {
426+ return err
427+ }
428+ defer handle.close()
429+ plane := handle.plane
430+ return runAppsDiff(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
431+ },
432+ }
433+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
434+ return cmd
435+}
436+
437+func runAppsDiff(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
438+ resp, err := plane.DiffApp(ctx, app)
439+ if err != nil {
440+ return err
441+ }
442+ if jsonOut {
443+ return writeJSON(out, resp)
444+ }
445+ if len(resp.Diff.Added) == 0 && len(resp.Diff.Removed) == 0 && len(resp.Diff.Changed) == 0 {
446+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No differences for %s.", app)))
447+ }
448+ for _, section := range []struct {
449+ title string
450+ paths []string
451+ }{
452+ {"added:", resp.Diff.Added},
453+ {"removed:", resp.Diff.Removed},
454+ {"changed:", resp.Diff.Changed},
455+ } {
456+ paths := append([]string(nil), section.paths...)
457+ sort.Strings(paths)
458+ for _, p := range paths {
459+ if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
460+ return err
461+ }
462+ }
463+ }
464+ return nil
465+}
466+
467+// newAppsSecretsCmd creates the `apps secrets` parent command.
468+func newAppsSecretsCmd() *cobra.Command {
469+ cmd := &cobra.Command{
470+ Use: "secrets",
471+ Short: "Manage app secret values",
472+ Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
473+--stdin (preferred), or an interactive prompt. Names must already exist in
474+desired or active state. Only key names are ever echoed back — never values.`,
475+ }
476+ cmd.AddCommand(newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
477+ return cmd
478+}
479+
480+// newAppsSecretsSetCmd creates `apps secrets set`.
481+func newAppsSecretsSetCmd() *cobra.Command {
482+ var service string
483+ var fromStdin bool
484+ var jsonOut bool
485+ cmd := &cobra.Command{
486+ Use: "set APP KEY=VALUE…",
487+ Short: "Write app secret values",
488+ Args: cobra.MinimumNArgs(0),
489+ RunE: func(cmd *cobra.Command, args []string) error {
490+ handle, err := resolveAppPlane()
491+ if err != nil {
492+ return err
493+ }
494+ defer handle.close()
495+ plane := handle.plane
496+ if len(args) == 0 {
497+ return fmt.Errorf("missing APP argument")
498+ }
499+ return runAppsSecretsSet(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), args[0], args[1:], service, fromStdin, jsonOut)
500+ },
501+ }
502+ cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")
503+ cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines from stdin")
504+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
505+ return cmd
506+}
507+
508+func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {
509+ if app == "" {
510+ return fmt.Errorf("missing APP argument")
511+ }
512+ if service == "" {
513+ return fmt.Errorf("missing required flag --service: secrets are service-scoped")
514+ }
515+ if fromStdin {
516+ stdinPairs, err := readSecretStdin(stdin)
517+ if err != nil {
518+ return err
519+ }
520+ pairs = append(pairs, stdinPairs...)
521+ }
522+ values, err := parseSecretPairs(pairs)
523+ if err != nil {
524+ return err
525+ }
526+ if len(values) == 0 {
527+ return fmt.Errorf("no secrets provided: pass KEY=VALUE arguments or --stdin")
528+ }
529+ if err := plane.SetAppSecrets(ctx, app, dto.AppSecretSetRequest{Service: service, Secrets: values}); err != nil {
530+ return err
531+ }
532+ keys := make([]string, 0, len(values))
533+ for k := range values {
534+ keys = append(keys, k)
535+ }
536+ sort.Strings(keys)
537+ if jsonOut {
538+ return writeJSON(out, map[string]any{"app": app, "service": service, "keys": keys})
539+ }
540+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Set %d secret(s) for %s/%s: %s", len(keys), app, service, strings.Join(keys, ", "))))
541+}
542+
543+// newAppsSecretsDeleteCmd creates `apps secrets delete`.
544+func newAppsSecretsDeleteCmd() *cobra.Command {
545+ var service string
546+ var jsonOut bool
547+ cmd := &cobra.Command{
548+ Use: "delete APP KEY",
549+ Short: "Delete an app secret value",
550+ Args: cobra.ExactArgs(2),
551+ RunE: func(cmd *cobra.Command, args []string) error {
552+ handle, err := resolveAppPlane()
553+ if err != nil {
554+ return err
555+ }
556+ defer handle.close()
557+ plane := handle.plane
558+ return runAppsSecretsDelete(cmd.Context(), plane, cmd.OutOrStdout(), args[0], args[1], service, jsonOut)
559+ },
560+ }
561+ cmd.Flags().StringVar(&service, "service", "", "Service the secret belongs to (required)")
562+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
563+ return cmd
564+}
565+
566+func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer, app, key, service string, jsonOut bool) error {
567+ if service == "" {
568+ return fmt.Errorf("missing required flag --service: secrets are service-scoped")
569+ }
570+ if key == "" {
571+ return fmt.Errorf("missing KEY argument")
572+ }
573+ if err := plane.DeleteAppSecret(ctx, app, dto.AppSecretDeleteRequest{Service: service, Key: key}); err != nil {
574+ return err
575+ }
576+ if jsonOut {
577+ return writeJSON(out, map[string]any{"app": app, "service": service, "deleted": key})
578+ }
579+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
580+}
581+
582+// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty;
583+// values may be empty (explicit empty secret).
584+func parseSecretPairs(pairs []string) (map[string]string, error) {
585+ values := make(map[string]string, len(pairs))
586+ for _, pair := range pairs {
587+ key, value, ok := strings.Cut(pair, "=")
588+ if !ok || key == "" {
589+ return nil, fmt.Errorf("invalid secret %q: expected KEY=VALUE", pair)
590+ }
591+ values[key] = value
592+ }
593+ return values, nil
594+}
595+
596+// readSecretStdin reads KEY=VALUE lines (blank lines ignored).
597+func readSecretStdin(in io.Reader) ([]string, error) {
598+ data, err := io.ReadAll(in)
599+ if err != nil {
600+ return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
601+ }
602+ var pairs []string
603+ for _, line := range strings.Split(string(data), "\n") {
604+ if trimmed := strings.TrimSpace(line); trimmed != "" {
605+ pairs = append(pairs, trimmed)
606+ }
607+ }
608+ return pairs, nil
609+}
610+
611+// newAppDeployCmd creates `deploy APP`. At cutover this replaces the
612+// domain-based deploy for apps; domain-based deploy keeps working for
613+// non-app resources until then (05-api-cli.md §5).
614+func newAppDeployCmd() *cobra.Command {
615+ var revision string
616+ var service string
617+ var jsonOut bool
618+ cmd := &cobra.Command{
619+ Use: "deploy APP",
620+ Short: "Activate an app revision",
621+ Args: cobra.ExactArgs(1),
622+ RunE: func(cmd *cobra.Command, args []string) error {
623+ handle, err := resolveAppPlane()
624+ if err != nil {
625+ return err
626+ }
627+ defer handle.close()
628+ plane := handle.plane
629+ return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), jsonOut)
630+ },
631+ }
632+ cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
633+ cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
634+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
635+ return cmd
636+}
637+
638+func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out io.Writer, jsonOut bool) error {
639+ resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})
640+ if err != nil {
641+ if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
642+ return conflictErr
643+ }
644+ return appMutationError("deploy", app, key, err)
645+ }
646+ if jsonOut {
647+ return writeJSON(out, resp)
648+ }
649+ return renderAppDeployResponse(out, resp)
650+}
651+
652+// newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
653+func newAppRestartCmd() *cobra.Command {
654+ var service string
655+ var jsonOut bool
656+ cmd := &cobra.Command{
657+ Use: "restart APP",
658+ Short: "Restart an app from pinned digests",
659+ Args: cobra.ExactArgs(1),
660+ RunE: func(cmd *cobra.Command, args []string) error {
661+ handle, err := resolveAppPlane()
662+ if err != nil {
663+ return err
664+ }
665+ defer handle.close()
666+ plane := handle.plane
667+ return runAppRestart(cmd.Context(), plane, args[0], service, cmd.OutOrStdout(), jsonOut)
668+ },
669+ }
670+ cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
671+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
672+ return cmd
673+}
674+
675+func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, out io.Writer, jsonOut bool) error {
676+ resp, key, err := plane.RestartApp(ctx, app, service)
677+ if err != nil {
678+ if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
679+ return conflictErr
680+ }
681+ return appMutationError("restart", app, key, err)
682+ }
683+ if jsonOut {
684+ return writeJSON(out, resp)
685+ }
686+ return renderAppDeployResponse(out, resp)
687+}
688+
689+// newAppStopCmd creates `stop APP`: durable stopped intent, data preserved.
690+func newAppStopCmd() *cobra.Command {
691+ var jsonOut bool
692+ cmd := &cobra.Command{
693+ Use: "stop APP",
694+ Short: "Stop an app (preserves all data)",
695+ Args: cobra.ExactArgs(1),
696+ RunE: func(cmd *cobra.Command, args []string) error {
697+ handle, err := resolveAppPlane()
698+ if err != nil {
699+ return err
700+ }
701+ defer handle.close()
702+ plane := handle.plane
703+ return runAppLifecycle(cmd.Context(), plane.StopApp, "stop", args[0], cmd.OutOrStdout(), jsonOut)
704+ },
705+ }
706+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
707+ return cmd
708+}
709+
710+// newAppStartCmd creates `start APP`: clears intent, ensures running.
711+func newAppStartCmd() *cobra.Command {
712+ var jsonOut bool
713+ cmd := &cobra.Command{
714+ Use: "start APP",
715+ Short: "Start a stopped app from active state",
716+ Args: cobra.ExactArgs(1),
717+ RunE: func(cmd *cobra.Command, args []string) error {
718+ handle, err := resolveAppPlane()
719+ if err != nil {
720+ return err
721+ }
722+ defer handle.close()
723+ plane := handle.plane
724+ return runAppLifecycle(cmd.Context(), plane.StartApp, "start", args[0], cmd.OutOrStdout(), jsonOut)
725+ },
726+ }
727+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
728+ return cmd
729+}
730+
731+// newAppRemoveCmd creates `remove APP`: withdraws workloads, retains data.
732+func newAppRemoveCmd() *cobra.Command {
733+ var jsonOut bool
734+ cmd := &cobra.Command{
735+ Use: "remove APP",
736+ Short: "Remove app workloads (volumes and secrets are retained)",
737+ Args: cobra.ExactArgs(1),
738+ RunE: func(cmd *cobra.Command, args []string) error {
739+ handle, err := resolveAppPlane()
740+ if err != nil {
741+ return err
742+ }
743+ defer handle.close()
744+ plane := handle.plane
745+ return runAppLifecycle(cmd.Context(), plane.RemoveApp, "remove", args[0], cmd.OutOrStdout(), jsonOut)
746+ },
747+ }
748+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
749+ return cmd
750+}
751+
752+// appLifecycleFunc is one body-less app lifecycle mutation.
753+type appLifecycleFunc func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
754+
755+func runAppLifecycle(ctx context.Context, fn appLifecycleFunc, op, app string, out io.Writer, jsonOut bool) error {
756+ resp, key, err := fn(ctx, app)
757+ if err != nil {
758+ if conflictErr, ok := renderAppOpConflict(out, op, app, key, err, jsonOut); ok {
759+ return conflictErr
760+ }
761+ return appMutationError(op, app, key, err)
762+ }
763+ if jsonOut {
764+ return writeJSON(out, resp)
765+ }
766+ return renderAppDeployResponse(out, resp)
767+}
768+
769+// renderAppDeployResponse renders the deploy outcome with terminal
770+// per-service results, cleanup warnings, and the effective/retained
771+// summaries. Sorts services for stable output.
772+func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
773+ if err := renderDeployHeadline(out, resp); err != nil {
774+ return err
775+ }
776+ if err := renderDeployServices(out, resp); err != nil {
777+ return err
778+ }
779+ return renderDeploySummaries(out, resp)
780+}
781+
782+func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
783+ headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
784+ if resp.Outcome == "success" {
785+ return cliWriteLine(out, cliRenderSuccess(headline))
786+ }
787+ return cliWriteLine(out, cliRenderWarning(headline))
788+}
789+
790+func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
791+ names := make([]string, 0, len(resp.Services))
792+ for name := range resp.Services {
793+ names = append(names, name)
794+ }
795+ sort.Strings(names)
796+ for _, name := range names {
797+ svc := resp.Services[name]
798+ detail := svc.Result + " " + svc.EffectiveRevision
799+ if svc.RestartUnsafe {
800+ detail += " restart_unsafe"
801+ }
802+ if svc.Error != "" {
803+ detail += ": " + svc.Error
804+ }
805+ if err := cliWriteLine(out, cliRenderMeta(" "+name+":", detail)); err != nil {
806+ return err
807+ }
808+ }
809+ for _, w := range resp.CleanupWarnings {
810+ if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("cleanup: %s left %s (%s)", w.Service, w.Leftover, w.Detail))); err != nil {
811+ return err
812+ }
813+ }
814+ return nil
815+}
816+
817+func renderDeploySummaries(out io.Writer, resp *dto.AppDeployResponse) error {
818+ if resp.Effective != nil && len(resp.Effective.Services) > 0 {
819+ pairs := make([]string, 0, len(resp.Effective.Services))
820+ for name, rev := range resp.Effective.Services {
821+ pairs = append(pairs, name+"="+rev)
822+ }
823+ sort.Strings(pairs)
824+ if err := cliWriteLine(out, cliRenderMeta("effective:", strings.Join(pairs, " "))); err != nil {
825+ return err
826+ }
827+ }
828+ if resp.Retained == nil {
829+ return nil
830+ }
831+ owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
832+ owned = append(owned, resp.Retained.Secrets...)
833+ if len(owned) > 0 {
834+ sort.Strings(owned)
835+ if err := cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))); err != nil {
836+ return err
837+ }
838+ }
839+ return nil
840+}
841+
842+// newAppStatusCmd creates `status APP`: effective vs observed, per service.
843+func newAppStatusCmd() *cobra.Command {
844+ var jsonOut bool
845+ cmd := &cobra.Command{
846+ Use: "status APP",
847+ Short: "Show effective vs observed state for an app",
848+ Args: cobra.ExactArgs(1),
849+ RunE: func(cmd *cobra.Command, args []string) error {
850+ handle, err := resolveAppPlane()
851+ if err != nil {
852+ return err
853+ }
854+ defer handle.close()
855+ plane := handle.plane
856+ return runAppStatus(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
857+ },
858+ }
859+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
860+ return cmd
861+}
862+
863+func runAppStatus(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
864+ resp, err := plane.ShowApp(ctx, app)
865+ if err != nil {
866+ return err
867+ }
868+ if jsonOut {
869+ return writeJSON(out, resp)
870+ }
871+ if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
872+ return err
873+ }
874+ for _, line := range renderActiveServices(resp.Active) {
875+ if err := cliWriteLine(out, line); err != nil {
876+ return err
877+ }
878+ }
879+ var running []string
880+ for _, svc := range resp.Active.Services {
881+ if svc.Container != "" {
882+ running = append(running, svc.Container)
883+ }
884+ }
885+ sort.Strings(running)
886+ observed := "none"
887+ if len(running) > 0 {
888+ observed = strings.Join(running, " ")
889+ }
890+ if err := cliWriteLine(out, cliRenderMeta("observed:", observed)); err != nil {
891+ return err
892+ }
893+ if resp.Intent.Stopped {
894+ return cliWriteLine(out, cliRenderWarning("stopped intent is set"))
895+ }
896+ return nil
897+}
898+
899+// appLogReader streams logs by Gordon app/service reference. The daemon
900+// resolves the reference through its authoritative ACTIVE record.
901+type appLogReader interface {
902+ GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error)
903+ StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error)
904+}
905+
906+// remoteAppLogReader adapts *remote.Client to appLogReader.
907+type remoteAppLogReader struct {
908+ client *remote.Client
909+}
910+
911+func (r *remoteAppLogReader) GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) {
912+ return r.client.GetContainerLogs(ctx, ref, lines)
913+}
914+
915+func (r *remoteAppLogReader) StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) {
916+ return r.client.StreamContainerLogs(ctx, ref, lines)
917+}
918+
919+// newAppLogsCmd creates `logs APP`.
920+func newAppLogsCmd() *cobra.Command {
921+ var service string
922+ var follow bool
923+ var tail int
924+ var jsonOut bool
925+ cmd := &cobra.Command{
926+ Use: "logs APP",
927+ Short: "Show logs for an app service",
928+ Args: cobra.ExactArgs(1),
929+ RunE: func(cmd *cobra.Command, args []string) error {
930+ client, err := resolveAppClient()
931+ if err != nil {
932+ return err
933+ }
934+ plane := NewRemoteControlPlane(client)
935+ return runAppLogs(cmd.Context(), plane, &remoteAppLogReader{client: client}, args[0], service, follow, tail, cmd.OutOrStdout(), jsonOut)
936+ },
937+ }
938+ cmd.Flags().StringVar(&service, "service", "", "Service to read logs from (required when the app has several)")
939+ cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
940+ cmd.Flags().IntVarP(&tail, "tail", "n", 50, "Number of lines to show")
941+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
942+ return cmd
943+}
944+
945+func runAppLogs(ctx context.Context, plane ControlPlane, reader appLogReader, app, service string, follow bool, tail int, out io.Writer, jsonOut bool) error {
946+ if follow && jsonOut {
947+ return fmt.Errorf("cannot combine --json with --follow: follow streams plain log lines")
948+ }
949+ show, err := plane.ShowApp(ctx, app)
950+ if err != nil {
951+ return err
952+ }
953+ ref, err := appLogRef(show, service)
954+ if err != nil {
955+ return err
956+ }
957+ if follow {
958+ stream, err := reader.StreamContainerLogs(ctx, ref, tail)
959+ if err != nil {
960+ return err
961+ }
962+ for line := range stream {
963+ if err := cliWriteLine(out, line); err != nil {
964+ return err
965+ }
966+ }
967+ return nil
968+ }
969+ lines, err := reader.GetContainerLogs(ctx, ref, tail)
970+ if err != nil {
971+ return err
972+ }
973+ if jsonOut {
974+ return writeJSON(out, map[string]any{"app": app, "service": service, "lines": lines})
975+ }
976+ for _, line := range lines {
977+ if err := cliWriteLine(out, line); err != nil {
978+ return err
979+ }
980+ }
981+ return nil
982+}
983+
984+// appLogRef resolves the container ref for one app service. When service
985+// is empty and the app has exactly one service, that service is used;
986+// otherwise --service is required. A service with no recorded container
987+// has nothing to stream yet.
988+func appLogRef(show *dto.AppShowResponse, service string) (string, error) {
989+ if len(show.Active.Services) == 0 {
990+ return "", fmt.Errorf("app %s has no active services yet", show.App)
991+ }
992+ if service == "" {
993+ if len(show.Active.Services) == 1 {
994+ for name := range show.Active.Services {
995+ service = name
996+ }
997+ } else {
998+ names := make([]string, 0, len(show.Active.Services))
999+ for name := range show.Active.Services {
1000+ names = append(names, name)
1001+ }
1002+ sort.Strings(names)
1003+ return "", fmt.Errorf("app %s has several services (%s): pass --service", show.App, strings.Join(names, ", "))
1004+ }
1005+ }
1006+ svc, ok := show.Active.Services[service]
1007+ if !ok {
1008+ return "", fmt.Errorf("app %s has no service %q", show.App, service)
1009+ }
1010+ if svc.Container == "" {
1011+ return "", fmt.Errorf("service %s has no recorded container yet", service)
1012+ }
1013+ return show.App + "/" + service, nil
1014+}
A internal/adapters/in/cli/apps_findings_test.go +75 -0
@@ -0,0 +1,75 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16+)
17+
18+func TestRunAppsApply_DryRunOmitsEmptyRevisionAndRendersDiff(t *testing.T) {
19+ plane := appPlane(t)
20+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(&dto.AppApplyResponse{
21+ App: "blog",
22+ Noop: false,
23+ Diff: dto.AppDiffSection{Added: []string{"service.web"}},
24+ Intent: "apply-7",
25+ }, nil).Once()
26+ var out bytes.Buffer
27+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,
28+ writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false))
29+ text := out.String()
30+ assert.Contains(t, text, "Validated (dry-run) blog")
31+ assert.NotContains(t, text, "()")
32+ assert.Contains(t, text, "added:")
33+ assert.Contains(t, text, "service.web")
34+}
35+
36+func TestRunAppsApply_NoopOmitsEmptyRevision(t *testing.T) {
37+ plane := appPlane(t)
38+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).
39+ Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once()
40+ var out bytes.Buffer
41+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,
42+ writeManifest(t, "x"), true, false, false))
43+ assert.Contains(t, out.String(), "No changes for blog")
44+ assert.NotContains(t, out.String(), "()")
45+}
46+
47+func TestRunAppLogs_RejectsJSONWithFollow(t *testing.T) {
48+ // The JSON/follow rejection happens before any read.
49+ plane := appPlane(t)
50+ err := runAppLogs(context.Background(), plane, &fakeAppLogReader{}, "blog", "web", true, 50, &bytes.Buffer{}, true)
51+ require.Error(t, err)
52+ assert.Contains(t, err.Error(), "--json")
53+ assert.Contains(t, err.Error(), "--follow")
54+}
55+
56+func TestAppLogRef_ZeroServicesAccurateError(t *testing.T) {
57+ empty := &dto.AppShowResponse{App: "new"}
58+ _, err := appLogRef(empty, "")
59+ require.Error(t, err)
60+ assert.Contains(t, err.Error(), "no active services")
61+}
62+
63+func TestRunStatusCmd_AppsLabelAndSortedHelpers(t *testing.T) {
64+ plane := climocks.NewMockControlPlane(t)
65+ plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{
66+ Apps: 2, RegistryDomain: "g.example.com",
67+ ContainerStatus: map[string]string{"zeta": "active", "alpha": "stopped"},
68+ }, nil).Once()
69+ var out bytes.Buffer
70+ require.NoError(t, runStatusCmd(context.Background(), plane, &out))
71+ text := out.String()
72+ assert.Contains(t, text, "Apps:")
73+ assert.NotContains(t, text, "Routes:")
74+ assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"))
75+}
A internal/adapters/in/cli/apps_local_integration_test.go +156 -0
@@ -0,0 +1,156 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "net/http"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/dto"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18+ adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
19+ "github.com/bnema/gordon/internal/adapters/localadmin"
20+ in "github.com/bnema/gordon/internal/boundaries/in"
21+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
22+ "github.com/bnema/gordon/internal/domain"
23+)
24+
25+// validLocalAppManifest is a minimal manifest accepted by the admin apply
26+// surface (one service, one HTTP host).
27+const validLocalAppManifest = `
28+name = "blog"
29+[[service]]
30+name = "web"
31+image = "img:1"
32+[[service.http]]
33+host = "blog.example.com"
34+port = 8080
35+`
36+
37+// TestSharedCommandLocalAdminComposition_Status executes a shared command
38+// through socket discovery, the Unix transport, and the local authority.
39+func TestSharedCommandLocalAdminComposition_Status(t *testing.T) {
40+ xdg := t.TempDir()
41+ t.Setenv("XDG_RUNTIME_DIR", xdg)
42+ withRemoteTarget(t, "")
43+
44+ configSvc := inmocks.NewMockConfigService(t)
45+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.test").Once()
46+ configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
47+ configSvc.EXPECT().GetServerPort().Return(8080).Once()
48+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true).Once()
49+ appSvc := inmocks.NewMockAppService(t)
50+ appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{App: "blog", Active: "rev-1", Converged: true}}, nil).Once()
51+
52+ handler := adminhttp.NewHandler(adminhttp.HandlerDeps{ConfigSvc: configSvc, AppSvc: appSvc, Log: zerowrap.Default()})
53+ shutdown := startLocalAuthority(t, filepath.Join(xdg, "gordon"), handler.LocalAuthority())
54+ defer shutdown()
55+ stubLocalAppClient(t, remote.NewLocalClient)
56+
57+ cmd := newStatusCmd()
58+ var out bytes.Buffer
59+ cmd.SetOut(&out)
60+ require.NoError(t, cmd.ExecuteContext(context.Background()))
61+ assert.Contains(t, out.String(), "registry.example.test")
62+ assert.Contains(t, out.String(), "blog")
63+}
64+
65+// TestAppLocalAdminComposition_EndToEnd composes the real local authority,
66+// Unix listener, discovery, transport, and app control plane.
67+func TestAppLocalAdminComposition_EndToEnd(t *testing.T) {
68+ xdg := t.TempDir()
69+ t.Setenv("XDG_RUNTIME_DIR", xdg)
70+ withRemoteTarget(t, "")
71+
72+ appSvc := inmocks.NewMockAppService(t)
73+ appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
74+ &in.AppApplyResult{App: "blog", ResultingRevision: "rev-2", Pending: true, IntentID: "apply-1"},
75+ nil, nil,
76+ ).Once()
77+ appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{
78+ {App: "blog", Desired: "rev-2", Active: "rev-1"},
79+ }, nil).Once()
80+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
81+ App: "blog",
82+ DesiredRevision: "rev-2",
83+ DesiredStatus: "pending",
84+ Services: map[string]in.AppServiceView{"web": {Container: "ctr-web"}},
85+ }, nil).Once()
86+ appSvc.EXPECT().Diff(mock.Anything, "blog").Return(domain.AppDiff{}, nil).Once()
87+ appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&domain.AppOperation{
88+ Op: "apply",
89+ App: "blog",
90+ Outcome: "succeeded",
91+ }, nil).Once()
92+
93+ handler := adminhttp.NewHandler(adminhttp.HandlerDeps{AppSvc: appSvc, Log: zerowrap.Default()})
94+ dir := filepath.Join(xdg, "gordon")
95+ shutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
96+
97+ // The CLI resolves the socket through real discovery and the Unix transport.
98+ stubLocalAppClient(t, remote.NewLocalClient)
99+
100+ handle, err := resolveAppPlane()
101+ require.NoError(t, err)
102+ defer handle.close()
103+ plane := handle.plane
104+
105+ ctx := context.Background()
106+
107+ apply, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: validLocalAppManifest})
108+ require.NoError(t, err)
109+ assert.Equal(t, "rev-2", apply.ResultingRevision)
110+
111+ apps, err := plane.ListApps(ctx)
112+ require.NoError(t, err)
113+ require.Len(t, apps, 1)
114+ assert.Equal(t, "blog", apps[0].App)
115+
116+ show, err := plane.ShowApp(ctx, "blog")
117+ require.NoError(t, err)
118+ assert.Equal(t, "ctr-web", show.Active.Services["web"].Container)
119+
120+ diff, err := plane.DiffApp(ctx, "blog")
121+ require.NoError(t, err)
122+ assert.Equal(t, "blog", diff.App)
123+
124+ // Idempotent-mutation recovery path: re-query the same key.
125+ op, err := plane.OperationByKey(ctx, "blog", "key-1")
126+ require.NoError(t, err)
127+ assert.Equal(t, "succeeded", op.Outcome)
128+
129+ // Shutdown removes only the daemon-owned socket; restart recreates it.
130+ shutdown()
131+ assert.NoFileExists(t, localadmin.SocketPath(dir))
132+
133+ restartShutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
134+ require.FileExists(t, localadmin.SocketPath(dir))
135+ restartShutdown()
136+ assert.NoFileExists(t, localadmin.SocketPath(dir))
137+}
138+
139+// startLocalAuthority serves the given handler on an owner-only Unix socket
140+// and returns a shutdown function that stops the server and removes the
141+// socket, mirroring daemon lifecycle.
142+func startLocalAuthority(t *testing.T, dir string, handler http.Handler) func() {
143+ t.Helper()
144+ ln, info, err := localadmin.Listen(dir)
145+ require.NoError(t, err)
146+
147+ srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
148+ go func() { _ = srv.Serve(ln) }()
149+
150+ return func() {
151+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
152+ defer cancel()
153+ _ = srv.Shutdown(ctx)
154+ require.NoError(t, localadmin.RemoveOwnedSocket(localadmin.SocketPath(dir), info))
155+ }
156+}
A internal/adapters/in/cli/apps_local_test.go +222 -0
@@ -0,0 +1,222 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16+ "github.com/bnema/gordon/internal/adapters/localadmin"
17+)
18+
19+// appPlaneFixtureHandler serves the same app DTO payloads a daemon would,
20+// independent of transport, so local and remote seams can be compared.
21+func appPlaneFixtureHandler(t *testing.T) http.Handler {
22+ t.Helper()
23+ mux := http.NewServeMux()
24+ mux.HandleFunc("/admin/apps", func(w http.ResponseWriter, _ *http.Request) {
25+ writeFixtureJSON(t, w, map[string]any{
26+ "apps": []dto.AppSummaryDTO{{App: "blog", Desired: "rev-2", Active: "rev-1", Converged: false}},
27+ })
28+ })
29+ mux.HandleFunc("/admin/apps/blog", func(w http.ResponseWriter, _ *http.Request) {
30+ writeFixtureJSON(t, w, dto.AppShowResponse{
31+ App: "blog",
32+ Desired: dto.AppDesiredDTO{Revision: "rev-2", Status: "pending"},
33+ Active: dto.AppActiveDTO{
34+ Converged: true,
35+ Services: map[string]dto.AppActiveServiceDTO{
36+ "web": {EffectiveRevision: "rev-1", Container: "ctr-web"},
37+ },
38+ },
39+ })
40+ })
41+ mux.HandleFunc("/admin/apps/blog/diff", func(w http.ResponseWriter, _ *http.Request) {
42+ writeFixtureJSON(t, w, dto.AppDiffResponse{App: "blog"})
43+ })
44+ mux.HandleFunc("/admin/apps/apply", func(w http.ResponseWriter, r *http.Request) {
45+ if r.Method != http.MethodPost {
46+ w.WriteHeader(http.StatusMethodNotAllowed)
47+ return
48+ }
49+ writeFixtureJSON(t, w, dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-3"})
50+ })
51+ return mux
52+}
53+
54+func writeFixtureJSON(t *testing.T, w http.ResponseWriter, payload any) {
55+ t.Helper()
56+ w.Header().Set("Content-Type", "application/json")
57+ require.NoError(t, json.NewEncoder(w).Encode(payload))
58+}
59+
60+// startUnixAppPlane serves the fixture over an owner-only Unix socket.
61+func startUnixAppPlane(t *testing.T) string {
62+ t.Helper()
63+ dir := t.TempDir()
64+ ln, _, err := localadmin.Listen(dir)
65+ require.NoError(t, err)
66+
67+ srv := &http.Server{Handler: appPlaneFixtureHandler(t), ReadHeaderTimeout: 5 * time.Second}
68+ go func() { _ = srv.Serve(ln) }()
69+ t.Cleanup(func() { _ = srv.Close() })
70+
71+ return localadmin.SocketPath(dir)
72+}
73+
74+// withRemoteTarget pins the CLI remote flags and clears the environment.
75+func withRemoteTarget(t *testing.T, target string) {
76+ t.Helper()
77+ origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
78+ t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
79+ remoteFlag, tokenFlag, insecureTLSFlag = target, "", false
80+
81+ t.Setenv("GORDON_REMOTE", "")
82+ t.Setenv("GORDON_TOKEN", "")
83+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
84+}
85+
86+func stubLocalAppClient(t *testing.T, fn func() (*remote.Client, error)) {
87+ t.Helper()
88+ restore := newLocalAppClient
89+ newLocalAppClient = fn
90+ t.Cleanup(func() { newLocalAppClient = restore })
91+}
92+
93+func TestResolveAppPlane_UsesLocalAdminSocket(t *testing.T) {
94+ socketPath := startUnixAppPlane(t)
95+ withRemoteTarget(t, "")
96+ stubLocalAppClient(t, func() (*remote.Client, error) {
97+ return remote.NewLocalClientForSocket(socketPath), nil
98+ })
99+
100+ handle, err := resolveAppPlane()
101+ require.NoError(t, err)
102+ defer handle.close()
103+ plane := handle.plane
104+
105+ apps, err := plane.ListApps(context.Background())
106+ require.NoError(t, err)
107+ require.Len(t, apps, 1)
108+ assert.Equal(t, "blog", apps[0].App)
109+
110+ show, err := plane.ShowApp(context.Background(), "blog")
111+ require.NoError(t, err)
112+ assert.Equal(t, "rev-2", show.Desired.Revision)
113+
114+ diff, err := plane.DiffApp(context.Background(), "blog")
115+ require.NoError(t, err)
116+ assert.Equal(t, "blog", diff.App)
117+
118+ apply, err := plane.ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "name = \"blog\"\n"})
119+ require.NoError(t, err)
120+ assert.Equal(t, "rev-3", apply.ResultingRevision)
121+}
122+
123+// TestResolveAppControlPlane_LocalAndRemoteDTOParity asserts the shared DTO
124+// seam yields identical values over both transports.
125+func TestControlPlane_LocalAndRemoteDTOParity(t *testing.T) {
126+ fixture := appPlaneFixtureHandler(t)
127+
128+ tcp := httptest.NewServer(fixture)
129+ t.Cleanup(tcp.Close)
130+
131+ dir := t.TempDir()
132+ ln, _, err := localadmin.Listen(dir)
133+ require.NoError(t, err)
134+ unixSrv := &http.Server{Handler: fixture, ReadHeaderTimeout: 5 * time.Second}
135+ go func() { _ = unixSrv.Serve(ln) }()
136+ t.Cleanup(func() { _ = unixSrv.Close() })
137+
138+ localPlane := NewRemoteControlPlane(remote.NewLocalClientForSocket(localadmin.SocketPath(dir)))
139+ remotePlane := NewRemoteControlPlane(remote.NewClient(tcp.URL))
140+
141+ ctx := context.Background()
142+
143+ localApps, err := localPlane.ListApps(ctx)
144+ require.NoError(t, err)
145+ remoteApps, err := remotePlane.ListApps(ctx)
146+ require.NoError(t, err)
147+ assert.Equal(t, remoteApps, localApps)
148+
149+ localShow, err := localPlane.ShowApp(ctx, "blog")
150+ require.NoError(t, err)
151+ remoteShow, err := remotePlane.ShowApp(ctx, "blog")
152+ require.NoError(t, err)
153+ assert.Equal(t, remoteShow, localShow)
154+
155+ localDiff, err := localPlane.DiffApp(ctx, "blog")
156+ require.NoError(t, err)
157+ remoteDiff, err := remotePlane.DiffApp(ctx, "blog")
158+ require.NoError(t, err)
159+ assert.Equal(t, remoteDiff, localDiff)
160+}
161+
162+// TestResolveAppControlPlane_ExplicitRemoteNeverProbesLocal pins that an
163+// explicit remote is authoritative: the local socket is never consulted, and
164+// a remote failure never falls back to it.
165+func TestResolveAppPlane_ExplicitRemoteNeverProbesLocal(t *testing.T) {
166+ t.Run("remote succeeds", func(t *testing.T) {
167+ tcp := httptest.NewServer(appPlaneFixtureHandler(t))
168+ t.Cleanup(tcp.Close)
169+ withRemoteTarget(t, tcp.URL)
170+
171+ probed := false
172+ stubLocalAppClient(t, func() (*remote.Client, error) {
173+ probed = true
174+ return nil, remote.ErrDaemonUnavailable
175+ })
176+
177+ handle, err := resolveAppPlane()
178+ require.NoError(t, err)
179+ defer handle.close()
180+ plane := handle.plane
181+
182+ apps, err := plane.ListApps(context.Background())
183+ require.NoError(t, err)
184+ require.Len(t, apps, 1)
185+ assert.False(t, probed, "local socket must not be probed when a remote is selected")
186+ })
187+
188+ t.Run("remote fails without local fallback", func(t *testing.T) {
189+ tcp := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
190+ url := tcp.URL
191+ tcp.Close()
192+
193+ withRemoteTarget(t, url)
194+
195+ probed := false
196+ stubLocalAppClient(t, func() (*remote.Client, error) {
197+ probed = true
198+ return remote.NewLocalClientForSocket("/nonexistent/admin.sock"), nil
199+ })
200+
201+ handle, err := resolveAppPlane()
202+ require.NoError(t, err, "client construction succeeds; the failure must surface on the request")
203+ defer handle.close()
204+ plane := handle.plane
205+
206+ _, err = plane.ListApps(context.Background())
207+ require.Error(t, err)
208+ assert.False(t, probed, "a failed remote must never fall back to the local socket")
209+ })
210+}
211+
212+func TestResolveAppPlane_ReportsDaemonUnavailable(t *testing.T) {
213+ withRemoteTarget(t, "")
214+ stubLocalAppClient(t, func() (*remote.Client, error) {
215+ return nil, remote.ErrDaemonUnavailable
216+ })
217+
218+ _, err := resolveAppPlane()
219+ require.Error(t, err)
220+ assert.ErrorContains(t, err, "daemon-unavailable")
221+ assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
222+}
A internal/adapters/in/cli/apps_test.go +531 -0
@@ -0,0 +1,531 @@
1+package cli
2+
3+// Tests for the app CLI surface (apps.go). The commands go through one
4+// ControlPlane served by the generated mock: JSON parity (text and --json
5+// carry equivalent semantics), no secret values in output, plan-contract
6+// errors, and outcome-unknown guidance.
7+
8+import (
9+ "bytes"
10+ "context"
11+ "encoding/json"
12+ "errors"
13+ "net/http"
14+ "os"
15+ "path/filepath"
16+ "strings"
17+ "testing"
18+
19+ "github.com/spf13/cobra"
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/mock"
22+ "github.com/stretchr/testify/require"
23+
24+ "github.com/bnema/gordon/internal/adapters/dto"
25+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
26+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
27+)
28+
29+// appPlane returns the generated control plane mock for one test.
30+func appPlane(t *testing.T) *climocks.MockControlPlane {
31+ t.Helper()
32+ return climocks.NewMockControlPlane(t)
33+}
34+
35+func writeManifest(t *testing.T, content string) string {
36+ t.Helper()
37+ path := filepath.Join(t.TempDir(), "app.toml")
38+ require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
39+ return path
40+}
41+
42+func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
43+ plane := appPlane(t)
44+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "x.toml", true, true, false)
45+ require.Error(t, err)
46+ assert.Contains(t, err.Error(), "--dry-run")
47+ assert.Contains(t, err.Error(), "--deploy")
48+}
49+
50+func TestRunAppsApply_RequiresFile(t *testing.T) {
51+ plane := appPlane(t)
52+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "", false, false, false)
53+ require.Error(t, err)
54+ assert.Contains(t, err.Error(), "--file")
55+}
56+
57+func TestRunAppsApply_JSONParity(t *testing.T) {
58+ want := &dto.AppApplyResponse{
59+ App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b",
60+ Pending: true, Intent: "apply-123",
61+ Diff: dto.AppDiffSection{Added: []string{"service.web"}},
62+ }
63+ plane := appPlane(t)
64+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
65+ var out bytes.Buffer
66+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,
67+ writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
68+ var got dto.AppApplyResponse
69+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
70+ assert.Equal(t, *want, got)
71+}
72+
73+func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
74+ plane := appPlane(t)
75+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
76+ &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
77+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
78+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
79+ var out bytes.Buffer
80+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,
81+ writeManifest(t, "x"), false, true, false))
82+ assert.Contains(t, out.String(), "rev-b")
83+}
84+
85+func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
86+ plane := appPlane(t)
87+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
88+ &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
89+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
90+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
91+ var out bytes.Buffer
92+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,
93+ writeManifest(t, "x"), false, true, true))
94+ var got struct {
95+ Apply dto.AppApplyResponse `json:"apply"`
96+ Deploy dto.AppDeployResponse `json:"deploy"`
97+ }
98+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
99+ assert.Equal(t, "apply-1", got.Apply.Intent)
100+ assert.Equal(t, "op-1", got.Deploy.Op)
101+}
102+
103+func TestRunAppsList_JSONParityAndSorting(t *testing.T) {
104+ plane := appPlane(t)
105+ plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{
106+ {App: "zeta", Converged: true},
107+ {App: "alpha", Stopped: true},
108+ }, nil)
109+ var out bytes.Buffer
110+ require.NoError(t, runAppsList(context.Background(), plane, &out, true))
111+ var got []dto.AppSummaryDTO
112+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
113+ require.Len(t, got, 2)
114+
115+ out.Reset()
116+ require.NoError(t, runAppsList(context.Background(), plane, &out, false))
117+ text := out.String()
118+ assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted")
119+ assert.Contains(t, text, "stopped")
120+}
121+
122+func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
123+ plane := appPlane(t)
124+ plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once()
125+ var out bytes.Buffer
126+ require.NoError(t, runAppsList(context.Background(), plane, &out, true))
127+ assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
128+}
129+
130+func TestRunAppsShow_JSONParity(t *testing.T) {
131+ want := &dto.AppShowResponse{
132+ App: "blog",
133+ Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"},
134+ Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{
135+ "web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true},
136+ }},
137+ Intent: dto.AppIntentDTO{Stopped: true},
138+ LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"},
139+ }
140+ plane := appPlane(t)
141+ plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil)
142+ var out bytes.Buffer
143+ require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true))
144+ var got dto.AppShowResponse
145+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
146+ assert.Equal(t, *want, got)
147+
148+ out.Reset()
149+ require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
150+ assert.Contains(t, out.String(), "restart_unsafe", "text shows WHY recovery is blocked")
151+}
152+
153+func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
154+ plane := appPlane(t)
155+ var gotApp string
156+ var gotReq dto.AppSecretSetRequest
157+ plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything).
158+ Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) {
159+ gotApp, gotReq = app, req
160+ }).Return(nil).Once()
161+ var out bytes.Buffer
162+ require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
163+ &out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false))
164+ text := out.String()
165+ assert.NotContains(t, text, "s3cr3t-hunter2")
166+ assert.NotContains(t, text, "abc")
167+ assert.Contains(t, text, "password")
168+ assert.Contains(t, text, "token")
169+ assert.Equal(t, "blog", gotApp)
170+ assert.Equal(t, "web", gotReq.Service)
171+ assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets)
172+}
173+
174+func TestRunAppsSecretsSet_RequiresService(t *testing.T) {
175+ plane := appPlane(t)
176+ err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
177+ &bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false)
178+ require.Error(t, err)
179+ assert.Contains(t, err.Error(), "--service")
180+}
181+
182+func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) {
183+ plane := appPlane(t)
184+ var gotReq dto.AppSecretSetRequest
185+ plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
186+ Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
187+ var out bytes.Buffer
188+ stdin := strings.NewReader("from_stdin=stdin-value\n\nempty_val=\n")
189+ require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
190+ &out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
191+ assert.Equal(t, map[string]string{
192+ "from_stdin": "stdin-value",
193+ "empty_val": "",
194+ "from_flag": "flag-value",
195+ }, gotReq.Secrets)
196+
197+ err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
198+ &bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false)
199+ require.Error(t, err)
200+ assert.Contains(t, err.Error(), "KEY=VALUE")
201+}
202+
203+func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204+ plane := appPlane(t)
205+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206+ nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
207+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, false)
208+ require.Error(t, err)
209+ assert.Contains(t, err.Error(), "outcome-unknown")
210+ assert.Contains(t, err.Error(), "key-abc")
211+ assert.Contains(t, err.Error(), "by-key")
212+}
213+
214+func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
215+ plane := appPlane(t)
216+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
217+ StatusCode: http.StatusConflict, Status: "409 Conflict",
218+ Response: dto.AppDeployResponse{
219+ Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
220+ Services: map[string]dto.AppServiceResultDTO{
221+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
222+ },
223+ },
224+ }).Once()
225+ var out bytes.Buffer
226+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, false)
227+ require.Error(t, err, "conflict must retain failure semantics")
228+ text := out.String()
229+ assert.Contains(t, text, "web:")
230+ assert.Contains(t, text, "nope")
231+ assert.Contains(t, err.Error(), "failed")
232+ assert.Contains(t, err.Error(), "op-9")
233+ assert.Contains(t, err.Error(), "key-9")
234+ assert.Contains(t, err.Error(), "by-key")
235+}
236+
237+func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
238+ plane := appPlane(t)
239+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
240+ StatusCode: http.StatusConflict, Status: "409 Conflict",
241+ Response: dto.AppDeployResponse{
242+ Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
243+ Services: map[string]dto.AppServiceResultDTO{
244+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
245+ },
246+ },
247+ }).Once()
248+ var out bytes.Buffer
249+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, true)
250+ require.Error(t, err)
251+ var got dto.AppDeployResponse
252+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
253+ assert.Equal(t, "op-9", got.Op)
254+ assert.Equal(t, "failed", got.Services["web"].Result)
255+}
256+
257+func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) {
258+ plane := appPlane(t)
259+ plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{
260+ StatusCode: http.StatusConflict, Status: "409 Conflict",
261+ Response: dto.AppDeployResponse{
262+ Op: "op-7", App: "blog", Outcome: "partial",
263+ Services: map[string]dto.AppServiceResultDTO{
264+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
265+ },
266+ },
267+ }).Once()
268+ var out bytes.Buffer
269+ err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false)
270+ require.Error(t, err)
271+ assert.Contains(t, out.String(), "busy")
272+ assert.Contains(t, err.Error(), "key-7")
273+ assert.Contains(t, err.Error(), "by-key")
274+}
275+
276+func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
277+ resp := &dto.AppDeployResponse{
278+ Op: "op-1", App: "blog", Outcome: "partial",
279+ Services: map[string]dto.AppServiceResultDTO{
280+ "web": {Result: "deployed", EffectiveRevision: "rev-b"},
281+ "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
282+ },
283+ CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
284+ Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
285+ Retained: &dto.AppRetainedDTO{Volumes: []string{"blog-db"}},
286+ }
287+ var out bytes.Buffer
288+ require.NoError(t, renderAppDeployResponse(&out, resp))
289+ text := out.String()
290+ assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
291+ assert.Contains(t, text, "restart_unsafe")
292+ assert.Contains(t, text, "cleanup:")
293+ assert.Contains(t, text, "blog-db")
294+}
295+
296+func TestAppLogRef_Selection(t *testing.T) {
297+ multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
298+ "web": {Container: "ctr-web"},
299+ "db": {Container: "ctr-db"},
300+ }}}
301+ _, err := appLogRef(multi, "")
302+ require.Error(t, err)
303+ assert.Contains(t, err.Error(), "--service")
304+
305+ ref, err := appLogRef(multi, "db")
306+ require.NoError(t, err)
307+ assert.Equal(t, "blog/db", ref)
308+
309+ _, err = appLogRef(multi, "missing")
310+ require.Error(t, err)
311+
312+ single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
313+ "web": {Container: "ctr-solo"},
314+ }}}
315+ ref, err = appLogRef(single, "")
316+ require.NoError(t, err)
317+ assert.Equal(t, "solo/web", ref)
318+
319+ empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
320+ "web": {},
321+ }}}
322+ _, err = appLogRef(empty, "web")
323+ require.Error(t, err)
324+ assert.Contains(t, err.Error(), "no recorded container")
325+}
326+
327+func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) {
328+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
329+ t.Setenv("GORDON_REMOTE", "")
330+ t.Setenv("GORDON_TOKEN", "")
331+ origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
332+ t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
333+ remoteFlag, tokenFlag, insecureTLSFlag = "", "", false
334+
335+ restore := newLocalAppClient
336+ newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable }
337+ t.Cleanup(func() { newLocalAppClient = restore })
338+
339+ _, err := resolveAppPlane()
340+ require.Error(t, err)
341+ assert.Contains(t, err.Error(), "daemon-unavailable")
342+}
343+
344+// TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md §5):
345+// every command exists with its frozen Use string and flags. The commands
346+// stay unregistered until cutover; this test is what keeps them referenced.
347+func TestAppCommandSurface(t *testing.T) {
348+ root := newAppsCmd()
349+ require.Equal(t, "apps", root.Use)
350+ subs := map[string]bool{}
351+ for _, sub := range root.Commands() {
352+ subs[sub.Name()] = true
353+ }
354+ for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} {
355+ assert.True(t, subs[name], "apps subcommand %s", name)
356+ }
357+
358+ secrets := newAppsSecretsCmd()
359+ secretSubs := map[string]bool{}
360+ for _, sub := range secrets.Commands() {
361+ secretSubs[sub.Name()] = true
362+ }
363+ assert.True(t, secretSubs["set"])
364+ assert.True(t, secretSubs["delete"])
365+
366+ flagged := map[*cobra.Command][]string{
367+ newAppsApplyCmd(): {"file", "dry-run", "deploy", "json"},
368+ newAppsListCmd(): {"json"},
369+ newAppsShowCmd(): {"json"},
370+ newAppsDiffCmd(): {"json"},
371+ newAppsSecretsSetCmd(): {"service", "stdin", "json"},
372+ newAppsSecretsDeleteCmd(): {"service", "json"},
373+ newAppDeployCmd(): {"revision", "service", "json"},
374+ newAppRestartCmd(): {"service", "json"},
375+ newAppStopCmd(): {"json"},
376+ newAppStartCmd(): {"json"},
377+ newAppRemoveCmd(): {"json"},
378+ newAppStatusCmd(): {"json"},
379+ newAppLogsCmd(): {"service", "follow", "tail", "json"},
380+ }
381+ for cmd, flags := range flagged {
382+ for _, flag := range flags {
383+ assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag)
384+ }
385+ }
386+
387+ assert.Equal(t, "deploy APP", newAppDeployCmd().Use)
388+ assert.Equal(t, "restart APP", newAppRestartCmd().Use)
389+ assert.Equal(t, "stop APP", newAppStopCmd().Use)
390+ assert.Equal(t, "start APP", newAppStartCmd().Use)
391+ assert.Equal(t, "remove APP", newAppRemoveCmd().Use)
392+ assert.Equal(t, "status APP", newAppStatusCmd().Use)
393+ assert.Equal(t, "logs APP", newAppLogsCmd().Use)
394+}
395+
396+func TestRunAppsDiff_TextAndJSON(t *testing.T) {
397+ diff := &dto.AppDiffResponse{
398+ App: "blog",
399+ Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}},
400+ }
401+ plane := appPlane(t)
402+ plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2)
403+ var out bytes.Buffer
404+ require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
405+ text := out.String()
406+ assert.Contains(t, text, "added:")
407+ assert.Contains(t, text, "removed:")
408+ assert.Contains(t, text, "changed:")
409+
410+ out.Reset()
411+ require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true))
412+ var got dto.AppDiffResponse
413+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
414+ assert.Equal(t, *diff, got)
415+
416+ plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once()
417+ out.Reset()
418+ require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
419+ assert.Contains(t, out.String(), "No differences")
420+}
421+
422+func TestRunAppsSecretsDelete(t *testing.T) {
423+ plane := appPlane(t)
424+ plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2)
425+ var out bytes.Buffer
426+ require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false))
427+ assert.Contains(t, out.String(), "password")
428+
429+ out.Reset()
430+ require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true))
431+ var got map[string]string
432+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
433+ assert.Equal(t, "password", got["deleted"])
434+
435+ err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false)
436+ require.Error(t, err)
437+ assert.Contains(t, err.Error(), "--service")
438+}
439+
440+func TestRunAppRestart_AndLifecycle(t *testing.T) {
441+ resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
442+ plane := appPlane(t)
443+ plane.EXPECT().RestartApp(mock.Anything, "blog", "").Return(resp, "key-lc", nil).Times(2)
444+ plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
445+ plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
446+ plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
447+ var out bytes.Buffer
448+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, false))
449+ assert.Contains(t, out.String(), "success")
450+
451+ out.Reset()
452+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, true))
453+ var got dto.AppDeployResponse
454+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
455+ assert.Equal(t, *resp, got)
456+
457+ for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} {
458+ out.Reset()
459+ require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false))
460+ assert.Contains(t, out.String(), "op-2")
461+ }
462+
463+ unknown := appPlane(t)
464+ unknown.EXPECT().StopApp(mock.Anything, "blog").Return(
465+ nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once()
466+ err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false)
467+ require.Error(t, err)
468+ assert.Contains(t, err.Error(), "key-x")
469+}
470+
471+func TestRunAppStatus_Observed(t *testing.T) {
472+ show := &dto.AppShowResponse{
473+ App: "blog",
474+ Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
475+ "web": {EffectiveRevision: "rev-b", Container: "ctr-b"},
476+ }},
477+ }
478+ plane := appPlane(t)
479+ plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2)
480+ var out bytes.Buffer
481+ require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false))
482+ assert.Contains(t, out.String(), "ctr-b")
483+
484+ out.Reset()
485+ require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true))
486+ var got dto.AppShowResponse
487+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
488+ assert.Equal(t, *show, got)
489+}
490+
491+// fakeAppLogReader scripts container log reads for app log tests.
492+type fakeAppLogReader struct {
493+ lines []string
494+ stream []string
495+}
496+
497+func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) {
498+ return f.lines, nil
499+}
500+
501+func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) {
502+ ch := make(chan string, len(f.stream))
503+ for _, line := range f.stream {
504+ ch <- line
505+ }
506+ close(ch)
507+ return ch, nil
508+}
509+
510+func TestRunAppLogs_TextJSONFollow(t *testing.T) {
511+ show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
512+ "web": {Container: "ctr-web"},
513+ }}}
514+ plane := appPlane(t)
515+ plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3)
516+ reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}}
517+
518+ var out bytes.Buffer
519+ require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false))
520+ assert.Equal(t, "l1\nl2\n", out.String())
521+
522+ out.Reset()
523+ require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true))
524+ var got map[string]any
525+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
526+ assert.Equal(t, "blog", got["app"])
527+
528+ out.Reset()
529+ require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
530+ assert.Equal(t, "s1\n", out.String())
531+}
D internal/adapters/in/cli/attachments.go +0 -516
@@ -1,516 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8-
9- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12- "github.com/bnema/gordon/internal/domain"
13-
14- "github.com/spf13/cobra"
15-)
16-
17-// newAttachmentsCmd creates the attachments command group.
18-func newAttachmentsCmd() *cobra.Command {
19- cmd := &cobra.Command{
20- Use: "attachments",
21- Aliases: []string{"attach"},
22- Short: "Manage container attachments",
23- Long: `Manage container attachments (databases, caches, queues) in the configuration.
24-
25-Attachments are service dependencies that run alongside your application containers.
26-They are defined per-domain or per-network-group in the configuration.
27-
28-When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
29-these commands operate on the remote server. Otherwise, they require access to
30-the local Gordon configuration.`,
31- }
32-
33- cmd.AddCommand(newAttachmentsListCmd())
34- cmd.AddCommand(newAttachmentsAddCmd())
35- cmd.AddCommand(newAttachmentsRemoveCmd())
36- cmd.AddCommand(newAttachmentsOrphansCmd())
37- cmd.AddCommand(newAttachmentsPruneCmd())
38- cmd.AddCommand(newAttachmentsPushCmd())
39-
40- return cmd
41-}
42-
43-// newAttachmentsListCmd creates the attachments list command.
44-func newAttachmentsListCmd() *cobra.Command {
45- var jsonOut bool
46-
47- cmd := &cobra.Command{
48- Use: "list [domain-or-group]",
49- Short: "List configured attachments",
50- Long: `List all configured attachments, or attachments for a specific domain/group.
51-
52-Examples:
53- gordon attachments list # List all attachments
54- gordon attachments list app.example.com # List attachments for domain
55- gordon attachments list backend # List attachments for network group`,
56- Args: cobra.MaximumNArgs(1),
57- RunE: func(cmd *cobra.Command, args []string) error {
58- ctx := cmd.Context()
59- target := ""
60- if len(args) > 0 {
61- target = args[0]
62- }
63-
64- if target != "" {
65- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
66- if err != nil {
67- return err
68- }
69- defer handle.close()
70- return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
71- }
72-
73- client, isRemote, err := GetRemoteClient()
74- if err != nil {
75- return err
76- }
77- if isRemote {
78- return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
79- }
80- return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
81- },
82- }
83-
84- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
85-
86- return cmd
87-}
88-
89-func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error {
90- if target != "" {
91- images, err := cp.GetAttachmentsConfig(ctx, target)
92- if err != nil {
93- return fmt.Errorf("failed to get attachments: %w", err)
94- }
95- if len(images) == 0 {
96- _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target)
97- return err
98- }
99- if jsonOut {
100- return writeJSON(out, map[string]any{"target": target, "images": images})
101- }
102- return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images)
103- }
104-
105- attachments, err := cp.GetAllAttachmentsConfig(ctx)
106- if err != nil {
107- return fmt.Errorf("failed to list attachments: %w", err)
108- }
109- if len(attachments) == 0 {
110- return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured"))
111- }
112- if jsonOut {
113- return writeJSON(out, map[string]any{"attachments": attachments})
114- }
115- return renderAllAttachmentsList(out, "Attachments", attachments)
116-}
117-
118-func renderAttachmentTargetList(out io.Writer, title string, images []string) error {
119- if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
120- return err
121- }
122- if err := cliWriteLine(out, ""); err != nil {
123- return err
124- }
125- for _, img := range images {
126- if err := cliWritef(out, " %s\n", img); err != nil {
127- return err
128- }
129- }
130- return nil
131-}
132-
133-func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error {
134- targets := make([]string, 0, len(attachments))
135- for t := range attachments {
136- targets = append(targets, t)
137- }
138- sort.Strings(targets)
139-
140- if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
141- return err
142- }
143- if err := cliWriteLine(out, ""); err != nil {
144- return err
145- }
146- for _, t := range targets {
147- images := attachments[t]
148- if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil {
149- return err
150- }
151- for _, img := range images {
152- if err := cliWritef(out, " %s\n", img); err != nil {
153- return err
154- }
155- }
156- }
157- return nil
158-}
159-
160-// runAttachmentsListRemote lists attachments from a remote Gordon instance.
161-func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
162- if target != "" {
163- // List for specific target
164- images, err := client.GetAttachmentsConfig(ctx, target)
165- if err != nil {
166- return fmt.Errorf("failed to get attachments: %w", err)
167- }
168-
169- if len(images) == 0 {
170- fmt.Printf("No attachments configured for '%s'\n", target)
171- return nil
172- }
173-
174- if jsonOut {
175- return writeJSON(out, map[string]any{"target": target, "images": images})
176- }
177-
178- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
179- fmt.Println()
180- for _, img := range images {
181- fmt.Printf(" %s\n", img)
182- }
183- return nil
184- }
185-
186- // List all attachments
187- attachments, err := client.GetAllAttachmentsConfig(ctx)
188- if err != nil {
189- return fmt.Errorf("failed to list attachments: %w", err)
190- }
191-
192- if len(attachments) == 0 {
193- fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
194- return nil
195- }
196-
197- if jsonOut {
198- return writeJSON(out, map[string]any{"attachments": attachments})
199- }
200-
201- // Sort targets for consistent output
202- targets := make([]string, 0, len(attachments))
203- for t := range attachments {
204- targets = append(targets, t)
205- }
206- sort.Strings(targets)
207-
208- fmt.Println(styles.Theme.Title.Render("Attachments"))
209- fmt.Println()
210-
211- for _, t := range targets {
212- images := attachments[t]
213- fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
214- for _, img := range images {
215- fmt.Printf(" %s\n", img)
216- }
217- }
218-
219- return nil
220-}
221-
222-// runAttachmentsListLocal lists attachments from local configuration.
223-func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
224- local, err := GetLocalServices(cfgPath)
225- if err != nil {
226- return fmt.Errorf("failed to initialize local services: %w", err)
227- }
228-
229- if target != "" {
230- // List for specific target
231- images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
232- if err != nil {
233- return fmt.Errorf("failed to get attachments: %w", err)
234- }
235-
236- if len(images) == 0 {
237- fmt.Printf("No attachments configured for '%s'\n", target)
238- return nil
239- }
240-
241- if jsonOut {
242- return writeJSON(out, map[string]any{"target": target, "images": images})
243- }
244-
245- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
246- fmt.Println()
247- for _, img := range images {
248- fmt.Printf(" %s\n", img)
249- }
250- return nil
251- }
252-
253- // List all attachments
254- attachments := local.GetConfigService().GetAllAttachments(ctx)
255-
256- if len(attachments) == 0 {
257- fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
258- return nil
259- }
260-
261- if jsonOut {
262- return writeJSON(out, map[string]any{"attachments": attachments})
263- }
264-
265- // Sort targets for consistent output
266- targets := make([]string, 0, len(attachments))
267- for t := range attachments {
268- targets = append(targets, t)
269- }
270- sort.Strings(targets)
271-
272- fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
273- fmt.Println()
274-
275- for _, t := range targets {
276- images := attachments[t]
277- fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
278- for _, img := range images {
279- fmt.Printf(" %s\n", img)
280- }
281- }
282-
283- return nil
284-}
285-
286-func newAttachmentsOrphansCmd() *cobra.Command {
287- var jsonOut bool
288- cmd := &cobra.Command{
289- Use: "orphans",
290- Short: "List orphaned attachment containers",
291- Args: cobra.NoArgs,
292- RunE: func(cmd *cobra.Command, args []string) error {
293- handle, err := resolveControlPlane(configPath)
294- if err != nil {
295- return err
296- }
297- defer handle.close()
298- lister, ok := handle.plane.(interface {
299- ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
300- })
301- if !ok {
302- return fmt.Errorf("attachment orphan listing is unavailable")
303- }
304- attachments, err := lister.ListOrphanedAttachments(cmd.Context())
305- if err != nil {
306- return fmt.Errorf("failed to list orphaned attachments: %w", err)
307- }
308- if jsonOut {
309- return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments})
310- }
311- return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments)
312- },
313- }
314- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
315- return cmd
316-}
317-
318-func newAttachmentsPruneCmd() *cobra.Command {
319- var jsonOut bool
320- var stop bool
321- cmd := &cobra.Command{
322- Use: "prune",
323- Short: "Review or stop orphaned attachment containers",
324- Args: cobra.NoArgs,
325- RunE: func(cmd *cobra.Command, args []string) error {
326- handle, err := resolveControlPlane(configPath)
327- if err != nil {
328- return err
329- }
330- defer handle.close()
331- cleaner, ok := handle.plane.(interface {
332- CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
333- })
334- if !ok {
335- return fmt.Errorf("attachment orphan cleanup is unavailable")
336- }
337- report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop)
338- if err != nil {
339- return fmt.Errorf("failed to cleanup orphaned attachments: %w", err)
340- }
341- if jsonOut {
342- return writeJSON(cmd.OutOrStdout(), report)
343- }
344- return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop)
345- },
346- }
347- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
348- cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes")
349- return cmd
350-}
351-
352-func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error {
353- if len(attachments) == 0 {
354- return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found"))
355- }
356- if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil {
357- return err
358- }
359- for _, a := range attachments {
360- label := a.Name
361- if label == "" {
362- label = a.ContainerID
363- }
364- if a.Image != "" {
365- label += " -> " + a.Image
366- }
367- if err := cliWriteLine(out, " "+label); err != nil {
368- return err
369- }
370- }
371- return nil
372-}
373-
374-func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error {
375- if report == nil {
376- return nil
377- }
378- if !stop {
379- if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil {
380- return err
381- }
382- return writeCleanupMessages(out, report.Warnings, report.Hints)
383- }
384- if len(report.RemovedContainers) == 0 {
385- if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil {
386- return err
387- }
388- } else {
389- if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil {
390- return err
391- }
392- for _, c := range report.RemovedContainers {
393- label := c.Name
394- if label == "" {
395- label = c.ID
396- }
397- if err := cliWriteLine(out, " "+label); err != nil {
398- return err
399- }
400- }
401- }
402- for _, failure := range report.PartialFailures {
403- if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil {
404- return err
405- }
406- }
407- return writeCleanupMessages(out, report.Warnings, report.Hints)
408-}
409-
410-// newAttachmentsAddCmd creates the attachments add command.
411-func newAttachmentsAddCmd() *cobra.Command {
412- cmd := &cobra.Command{
413- Use: "add <domain-or-group> <image>",
414- Short: "Add an attachment",
415- Long: `Add a container attachment to a domain or network group.
416-
417-Note: Attachments require network_isolation.enabled = true in your configuration.
418-Without network isolation, containers use Docker's default bridge network which
419-does not provide DNS resolution - your app won't be able to reach attachments
420-by hostname (e.g., 'postgres:5432').
421-
422-Examples:
423- gordon attachments add app.example.com postgres:18
424- gordon attachments add backend redis:7-alpine
425- gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
426- Args: cobra.ExactArgs(2),
427- RunE: func(cmd *cobra.Command, args []string) error {
428- ctx := cmd.Context()
429- target := args[0]
430- image := args[1]
431-
432- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
433- if err != nil {
434- return err
435- }
436- defer handle.close()
437-
438- if !handle.isRemote {
439- local, err := GetLocalServices(configPath)
440- if err != nil {
441- return fmt.Errorf("failed to initialize local services: %w", err)
442- }
443-
444- // Warn if network isolation is disabled
445- if !local.GetConfigService().IsNetworkIsolationEnabled() {
446- fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
447- fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution."))
448- fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname."))
449- fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
450- fmt.Println()
451- }
452- }
453-
454- if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
455- return fmt.Errorf("failed to add attachment: %w", err)
456- }
457-
458- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
459- return nil
460- },
461- }
462-
463- return cmd
464-}
465-
466-// newAttachmentsRemoveCmd creates the attachments remove command.
467-func newAttachmentsRemoveCmd() *cobra.Command {
468- var force bool
469-
470- cmd := &cobra.Command{
471- Use: "remove <domain-or-group> <image>",
472- Short: "Remove an attachment",
473- Long: `Remove a container attachment from a domain or network group.
474-
475-Examples:
476- gordon attachments remove app.example.com postgres:15
477- gordon attachments remove backend redis:7-alpine --force`,
478- Args: cobra.ExactArgs(2),
479- RunE: func(cmd *cobra.Command, args []string) error {
480- ctx := cmd.Context()
481- target := args[0]
482- image := args[1]
483-
484- // Confirm unless --force
485- if !force {
486- confirmed, err := components.RunConfirm(
487- fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
488- components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
489- )
490- if err != nil {
491- return err
492- }
493- if !confirmed {
494- fmt.Println(styles.Theme.Muted.Render("Cancelled"))
495- return nil
496- }
497- }
498-
499- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
500- if err != nil {
501- return err
502- }
503- defer handle.close()
504- if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
505- return fmt.Errorf("failed to remove attachment: %w", err)
506- }
507-
508- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
509- return nil
510- },
511- }
512-
513- cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
514-
515- return cmd
516-}
D internal/adapters/in/cli/attachments_push.go +0 -221
@@ -1,221 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8- "strings"
9-
10- "github.com/spf13/cobra"
11-
12- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
13- "github.com/bnema/gordon/pkg/validation"
14-)
15-
16-var (
17- resolveControlPlaneFn = resolveControlPlane
18- determineVersionFn = determineVersion
19-)
20-
21-// attachmentPushRequest holds all inputs for the attachments push command.
22-type attachmentPushRequest struct {
23- ImageArg string
24- Tag string
25- Build buildConfig
26-}
27-
28-func newAttachmentsPushCmd() *cobra.Command {
29- var (
30- build bool
31- platform string
32- tag string
33- dockerfile string
34- buildArgs []string
35- )
36-
37- cmd := &cobra.Command{
38- Use: "push <image>",
39- Short: "Build, tag, and push an attachment image",
40- Args: cobra.ExactArgs(1),
41- RunE: func(cmd *cobra.Command, args []string) error {
42- return runAttachmentsPush(cmd.Context(), attachmentPushRequest{
43- ImageArg: args[0],
44- Tag: tag,
45- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
46- }, cmd.OutOrStdout())
47- },
48- }
49-
50- cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
51- cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
52- cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
53- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
54- cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
55-
56- return cmd
57-}
58-
59-func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error {
60- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
61- if err != nil {
62- return err
63- }
64-
65- inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg)
66- if err != nil {
67- return err
68- }
69-
70- var handle *controlPlaneHandle
71- if inferredRemote != nil {
72- handle = newRemoteControlPlaneHandle(inferredRemote)
73- if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
74- return err
75- }
76- } else {
77- handle, err = resolveControlPlaneFn(configPath)
78- if err != nil {
79- return err
80- }
81- }
82- defer handle.close()
83-
84- for _, ba := range req.Build.BuildArgs {
85- if err := validateBuildArg(ba); err != nil {
86- return err
87- }
88- }
89-
90- registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, req.ImageArg)
91- if err != nil {
92- return err
93- }
94-
95- version, err := resolveVersionWithFn(ctx, req.Tag)
96- if err != nil {
97- return err
98- }
99-
100- img := imagePush{
101- Registry: registry,
102- ImageName: imageName,
103- Version: version,
104- }
105- img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
106-
107- var imageOps pushImageOps
108- if inferredRemote != nil {
109- imageOps, err = newImageOpsForResolvedRemote(inferredRemote)
110- } else {
111- imageOps, err = newImageOpsFn()
112- }
113- if err != nil {
114- return err
115- }
116-
117- if err := printAttachmentPushInfo(out, req.ImageArg, targets, img.VersionRef, img.LatestRef, version); err != nil {
118- return err
119- }
120-
121- build := buildConfig{
122- Enabled: req.Build.Enabled,
123- Platform: req.Build.Platform,
124- Dockerfile: dockerfile,
125- BuildArgs: req.Build.BuildArgs,
126- }
127-
128- if err := performAttachmentPush(ctx, imageOps, build, img); err != nil {
129- return err
130- }
131-
132- return cliWriteLine(out, styles.RenderSuccess("Push complete"))
133-}
134-
135-func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
136- if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
137- return err
138- }
139- if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
140- return err
141- }
142- if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
143- return err
144- }
145- if version != "latest" {
146- if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
147- return err
148- }
149- }
150- return nil
151-}
152-
153-func performAttachmentPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
154- if build.Enabled {
155- return buildAndPush(ctx, ops, build, img)
156- }
157- return tagAndPush(ctx, ops, img)
158-}
159-
160-func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
161- imageName = normalizeAttachmentImageName(imageArg)
162-
163- targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
164- if err != nil {
165- return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
166- }
167- if len(targets) == 0 {
168- return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
169- }
170-
171- status, err := cp.GetStatus(ctx)
172- if err != nil {
173- return "", "", nil, fmt.Errorf("failed to get status: %w", err)
174- }
175-
176- return status.RegistryDomain, imageName, targets, nil
177-}
178-
179-func normalizeAttachmentImageName(imageArg string) string {
180- name := imageArg
181- if idx := strings.Index(name, "@"); idx != -1 {
182- name = name[:idx]
183- }
184- if idx := strings.LastIndex(name, ":"); idx != -1 {
185- slashIdx := strings.LastIndex(name, "/")
186- if idx > slashIdx {
187- name = name[:idx]
188- }
189- }
190- parts := strings.SplitN(name, "/", 2)
191- if len(parts) == 2 {
192- host := parts[0]
193- if strings.ContainsAny(host, ".:") || host == "localhost" {
194- return parts[1]
195- }
196- }
197- return name
198-}
199-
200-func formatAttachmentTargets(targets []string) string {
201- cloned := append([]string(nil), targets...)
202- sort.Strings(cloned)
203- return fmt.Sprintf("%v", cloned)
204-}
205-
206-func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
207- version := determineVersionFn(ctx, tag)
208- if version != "latest" {
209- if err := validateVersionTag(version); err != nil {
210- return "", err
211- }
212- }
213- return version, nil
214-}
215-
216-func validateVersionTag(version string) error {
217- if err := validation.ValidateReference(version); err != nil {
218- return fmt.Errorf("invalid version tag %q: %w", version, err)
219- }
220- return nil
221-}
D internal/adapters/in/cli/attachments_push_test.go +0 -145
@@ -1,145 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "testing"
7-
8- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
9- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/mock"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestResolveAttachmentImage_OneTarget(t *testing.T) {
16- cpMock := climocks.NewMockControlPlane(t)
17- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once()
18- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
19-
20- registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "postgres")
21-
22- require.NoError(t, err)
23- assert.Equal(t, "registry.example.com", registry)
24- assert.Equal(t, "postgres", imageName)
25- assert.Equal(t, []string{"app.example.com"}, targets)
26-}
27-
28-func TestResolveAttachmentImage_MultipleTargets(t *testing.T) {
29- cpMock := climocks.NewMockControlPlane(t)
30- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "redis").Return([]string{"app.example.com", "backend"}, nil).Once()
31- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
32-
33- registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "redis")
34-
35- require.NoError(t, err)
36- assert.Equal(t, "registry.example.com", registry)
37- assert.Equal(t, "redis", imageName)
38- assert.Equal(t, []string{"app.example.com", "backend"}, targets)
39-}
40-
41-func TestResolveAttachmentImage_NotConfigured(t *testing.T) {
42- cpMock := climocks.NewMockControlPlane(t)
43- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return(nil, nil).Once()
44-
45- _, _, _, err := resolveAttachmentImage(context.Background(), cpMock, "postgres")
46-
47- require.Error(t, err)
48- assert.Contains(t, err.Error(), "not configured as an attachment")
49-}
50-
51-func TestAttachmentPushCmd_NoDeploy(t *testing.T) {
52- origResolveControlPlane := resolveControlPlaneFn
53- origDetermineVersion := determineVersionFn
54- origNewImageOps := newImageOpsFn
55- t.Cleanup(func() {
56- resolveControlPlaneFn = origResolveControlPlane
57- determineVersionFn = origDetermineVersion
58- newImageOpsFn = origNewImageOps
59- })
60-
61- cpMock := climocks.NewMockControlPlane(t)
62- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once()
63- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
64-
65- resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) {
66- return &controlPlaneHandle{plane: cpMock}, nil
67- }
68- determineVersionFn = func(context.Context, string) string { return "v1.2.3" }
69- pushCalled := false
70- newImageOpsFn = func() (pushImageOps, error) {
71- m := climocks.NewMockpushImageOps(t)
72- m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
73- m.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
74- m.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
75- pushCalled = true
76- }).Return(nil)
77- return m, nil
78- }
79-
80- cmd := newAttachmentsPushCmd()
81- out := new(bytes.Buffer)
82- cmd.SetOut(out)
83- cmd.SetErr(out)
84- cmd.SetArgs([]string{"postgres"})
85-
86- err := cmd.Execute()
87-
88- require.NoError(t, err)
89- assert.True(t, pushCalled)
90- assert.Contains(t, out.String(), "Push complete")
91-}
92-
93-func TestAttachmentPushCmd_TaggedImageInputBuildsValidRefs(t *testing.T) {
94- origResolveControlPlane := resolveControlPlaneFn
95- origDetermineVersion := determineVersionFn
96- origNewImageOps := newImageOpsFn
97- t.Cleanup(func() {
98- resolveControlPlaneFn = origResolveControlPlane
99- determineVersionFn = origDetermineVersion
100- newImageOpsFn = origNewImageOps
101- })
102-
103- cpMock := climocks.NewMockControlPlane(t)
104- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres:18").Return([]string{"app.example.com"}, nil).Once()
105- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
106-
107- resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) {
108- return &controlPlaneHandle{plane: cpMock}, nil
109- }
110- determineVersionFn = func(context.Context, string) string { return "v1.2.3" }
111-
112- var gotVersionRef string
113- var gotLatestRef string
114- newImageOpsFn = func() (pushImageOps, error) {
115- m := climocks.NewMockpushImageOps(t)
116- m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
117- m.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
118- m.On("Push", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
119- ref := args.Get(1).(string)
120- if gotVersionRef == "" {
121- gotVersionRef = ref
122- } else if gotLatestRef == "" {
123- gotLatestRef = ref
124- }
125- }).Return(nil)
126- return m, nil
127- }
128-
129- cmd := newAttachmentsPushCmd()
130- cmd.SetOut(new(bytes.Buffer))
131- cmd.SetErr(new(bytes.Buffer))
132- cmd.SetArgs([]string{"postgres:18"})
133-
134- err := cmd.Execute()
135-
136- require.NoError(t, err)
137- assert.Equal(t, "registry.example.com/postgres:v1.2.3", gotVersionRef)
138- assert.Equal(t, "registry.example.com/postgres:latest", gotLatestRef)
139-}
140-
141-func TestNormalizeAttachmentImageName_PreservesNamespaceWithoutRegistryHost(t *testing.T) {
142- got := normalizeAttachmentImageName("myorg/postgres:18")
143-
144- assert.Equal(t, "myorg/postgres", got)
145-}
M internal/adapters/in/cli/auth.go +88 -61
@@ -60,11 +60,13 @@ func newAuthTokenCmd() *cobra.Command {
6060 // newTokenGenerateCmd creates the token generate command.
6161 func newTokenGenerateCmd() *cobra.Command {
6262 var (
63- subject string
64- scopes string
65- expiry string
66- configPath string
67- repo string
63+ subject string
64+ scopes string
65+ expiry string
66+ cliConfigPath string
67+ repo string
68+ rawOut bool
69+ jsonOut bool
6870 )
6971
7072 cmd := &cobra.Command{
@@ -84,12 +86,13 @@ Registry scopes (for Docker push/pull):
8486 Admin scopes (for remote CLI access):
8587 Format: admin:<resource>:<actions>
8688
87- Resources: routes, secrets, config, status, logs, volumes, * (all)
89+ Resources: apps, secrets, config, status, logs, volumes, * (all)
8890 Actions: read, write, * (all)
8991
9092 Examples:
9193 admin:*:* Full admin access (recommended for CLI)
92- admin:routes:read Read-only routes access
94+ admin:apps:read Read-only apps access
95+ admin:apps:write App mutations (apply, deploy, lifecycle)
9396 admin:status:read Read-only status access
9497 admin:logs:read Read-only log access
9598 admin:volumes:read Read-only volume access
@@ -102,8 +105,8 @@ Repository scoping:
102105 Combined examples:
103106 --scopes "push,pull" All repos, registry only (default)
104107 --scopes "push" --repo myapp Push to myapp only
105- --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
106- --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108+ --scopes "push,pull,admin:apps:read" Minimum CI scope (all repos)
109+ --scopes "push,admin:apps:read" --repo app Minimum CI scope (specific repo)
107110
108111 EXPIRY:
109112
@@ -114,15 +117,18 @@ Supports human-friendly durations:
114117 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
115118 Use --expiry=0 for a token that never expires (useful for CI).`,
116119 RunE: func(cmd *cobra.Command, args []string) error {
117- return runTokenGenerate(subject, scopes, expiry, configPath, repo)
120+ return runTokenGenerate(subject, scopes, expiry, cliConfigPath, repo, cmd.OutOrStdout(), rawOut, jsonOut)
118121 },
119122 }
120123
121124 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
122125 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
123126 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
124- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
127+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
125128 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
129+ cmd.Flags().BoolVar(&rawOut, "raw", false, "Output only the raw token")
130+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
131+ cmd.MarkFlagsMutuallyExclusive("raw", "json")
126132
127133 _ = cmd.MarkFlagRequired("subject")
128134
@@ -132,8 +138,8 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
132138 // newTokenListCmd creates the token list command.
133139 func newTokenListCmd() *cobra.Command {
134140 var (
135- configPath string
136- jsonOut bool
141+ cliConfigPath string
142+ jsonOut bool
137143 )
138144
139145 cmd := &cobra.Command{
@@ -141,11 +147,11 @@ func newTokenListCmd() *cobra.Command {
141147 Short: "List all authentication tokens",
142148 Long: `List all stored authentication tokens with their subjects and expiry information.`,
143149 RunE: func(cmd *cobra.Command, args []string) error {
144- return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
150+ return runTokenList(cliConfigPath, cmd.OutOrStdout(), jsonOut)
145151 },
146152 }
147153
148- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
154+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
149155 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
150156
151157 return cmd
@@ -154,8 +160,8 @@ func newTokenListCmd() *cobra.Command {
154160 // newTokenRevokeCmd creates the token revoke command.
155161 func newTokenRevokeCmd() *cobra.Command {
156162 var (
157- configPath string
158- all bool
163+ cliConfigPath string
164+ all bool
159165 )
160166
161167 cmd := &cobra.Command{
@@ -169,16 +175,16 @@ Examples:
169175 Args: cobra.MaximumNArgs(1),
170176 RunE: func(cmd *cobra.Command, args []string) error {
171177 if all {
172- return runTokenRevokeAll(configPath)
178+ return runTokenRevokeAll(cliConfigPath)
173179 }
174180 if len(args) == 0 {
175181 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
176182 }
177- return runTokenRevoke(args[0], configPath)
183+ return runTokenRevoke(args[0], cliConfigPath)
178184 },
179185 }
180186
181- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
187+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
182188 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
183189
184190 return cmd
@@ -188,18 +194,21 @@ Examples:
188194 func newAuthInternalCmd() *cobra.Command {
189195 return &cobra.Command{
190196 Use: "internal",
191- Short: "Show internal registry credentials for manual recovery",
192- Long: `Display the auto-generated internal registry credentials.
197+ Short: "Show server-local registry recovery credentials",
198+ Long: `Display the server's auto-generated internal registry credentials.
199+
200+This sensitive recovery command is available only when Gordon runs locally on
201+the server. The daemon normally uses these ephemeral credentials automatically
202+to pull from its registry; remote clients and ordinary pushes use scoped tokens.
193203
194-These credentials are used by Gordon for pulling images from the local registry
195-(localhost:5000) during internal deploys. You can use them for manual recovery:
204+For local recovery only:
196205
197- docker login localhost:5000 -u gordon-internal -p <password>
206+ gordon auth internal
207+ docker login localhost:<registry-port> -u gordon-internal --password-stdin
198208
199-Note: Credentials are regenerated each time Gordon starts, and are only
200-available while Gordon is running.`,
209+Credentials are regenerated whenever the Gordon daemon starts.`,
201210 RunE: func(cmd *cobra.Command, args []string) error {
202- return runShowInternalAuth()
211+ return runShowInternalAuth(cmd.OutOrStdout())
203212 },
204213 }
205214 }
@@ -402,21 +411,31 @@ func runAuthLogout(out io.Writer) error {
402411 }
403412
404413 // runShowInternalAuth displays the internal registry credentials.
405-func runShowInternalAuth() error {
414+func runShowInternalAuth(out io.Writer) error {
415+ if _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag); err != nil {
416+ return err
417+ } else if isRemote {
418+ return fmt.Errorf("auth internal is server-local only; run it directly on the Gordon server")
419+ }
420+
406421 creds, err := app.GetInternalCredentials()
407422 if err != nil {
408423 return err
409424 }
410425
411- fmt.Println("Internal Registry Credentials")
412- fmt.Println("==============================")
413- fmt.Printf("Username: %s\n", creds.Username)
414- fmt.Printf("Password: %s\n", creds.Password)
415- fmt.Println()
416- fmt.Println("Usage:")
417- fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
418-
419- return nil
426+ if err := cliWriteLine(out, "Internal Registry Credentials"); err != nil {
427+ return err
428+ }
429+ if err := cliWriteLine(out, "=============================="); err != nil {
430+ return err
431+ }
432+ if err := cliWritef(out, "Username: %s\n", creds.Username); err != nil {
433+ return err
434+ }
435+ if err := cliWritef(out, "Password: %s\n", creds.Password); err != nil {
436+ return err
437+ }
438+ return cliWriteLine(out, "\nThese credentials are sensitive and intended only for server-local recovery.")
420439 }
421440
422441 // runTokenGenerate generates a new authentication token.
@@ -463,8 +482,8 @@ func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
463482 return scopes, nil
464483 }
465484
466-func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
467- cfg, err := loadAuthConfig(configPath)
485+func runTokenGenerate(subject, scopesStr, expiryStr, cliConfigPath, repo string, out io.Writer, rawOut, jsonOut bool) error {
486+ cfg, err := loadAuthConfig(cliConfigPath)
468487 if err != nil {
469488 return err
470489 }
@@ -497,26 +516,34 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
497516 return fmt.Errorf("failed to generate token: %w", err)
498517 }
499518
500- fmt.Println("Token generated successfully!")
501- fmt.Printf("Subject: %s\n", subject)
502- fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
503- if expiry == 0 {
504- fmt.Println("Expiry: never")
505- } else {
506- fmt.Printf("Expiry: %s\n", expiry)
519+ if rawOut {
520+ return cliWriteLine(out, token)
521+ }
522+ if jsonOut {
523+ return writeJSON(out, map[string]any{
524+ "subject": subject,
525+ "scopes": scopes,
526+ "expiry": expiry.String(),
527+ "token": token,
528+ })
507529 }
508- fmt.Println()
509- fmt.Println("Token (use as password with docker login):")
510- fmt.Println(token)
511- fmt.Println()
512- fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
513530
514- return nil
531+ if err := cliWriteLine(out, "Token generated successfully!"); err != nil {
532+ return err
533+ }
534+ if err := cliWritef(out, "Subject: %s\nScopes: %s\n", subject, strings.Join(scopes, ", ")); err != nil {
535+ return err
536+ }
537+ expiryLabel := expiry.String()
538+ if expiry == 0 {
539+ expiryLabel = "never"
540+ }
541+ return cliWritef(out, "Expiry: %s\n\nToken (use as password with docker login):\n%s\n\nUsage: docker login -u %s -p <token> <registry>\n", expiryLabel, token, subject)
515542 }
516543
517544 // runTokenList lists all stored tokens.
518-func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
519- cfg, err := loadAuthConfig(configPath)
545+func runTokenList(cliConfigPath string, out io.Writer, jsonOut bool) error {
546+ cfg, err := loadAuthConfig(cliConfigPath)
520547 if err != nil {
521548 return err
522549 }
@@ -579,8 +606,8 @@ func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
579606 }
580607
581608 // runTokenRevoke revokes a token by ID.
582-func runTokenRevoke(tokenID, configPath string) error {
583- cfg, err := loadAuthConfig(configPath)
609+func runTokenRevoke(tokenID, cliConfigPath string) error {
610+ cfg, err := loadAuthConfig(cliConfigPath)
584611 if err != nil {
585612 return err
586613 }
@@ -601,8 +628,8 @@ func runTokenRevoke(tokenID, configPath string) error {
601628 }
602629
603630 // runTokenRevokeAll revokes all tokens.
604-func runTokenRevokeAll(configPath string) error {
605- cfg, err := loadAuthConfig(configPath)
631+func runTokenRevokeAll(cliConfigPath string) error {
632+ cfg, err := loadAuthConfig(cliConfigPath)
606633 if err != nil {
607634 return err
608635 }
@@ -635,14 +662,14 @@ type cliConfig struct {
635662 }
636663
637664 // loadAuthConfig loads the configuration needed for auth CLI commands.
638-func loadAuthConfig(configPath string) (*cliConfig, error) {
665+func loadAuthConfig(cliConfigPath string) (*cliConfig, error) {
639666 v := viper.New()
640667
641668 // Set defaults
642669 v.SetDefault("server.data_dir", app.DefaultDataDir())
643670 v.SetDefault("auth.secrets_backend", "unsafe")
644671
645- app.ConfigureViper(v, configPath)
672+ app.ConfigureViper(v, cliConfigPath)
646673
647674 if err := v.ReadInConfig(); err != nil {
648675 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
M internal/adapters/in/cli/auth_scopes_test.go +2 -2
@@ -26,9 +26,9 @@ func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
2626 }
2727
2828 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
29- got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
29+ got, err := parseAndConvertScopes("push,pull,admin:apps:read", "myapp")
3030 require.NoError(t, err)
31- assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
31+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:apps:read"}, got)
3232 }
3333
3434 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
D internal/adapters/in/cli/autoroute_allow.go +0 -112
@@ -1,112 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7-
8- "github.com/spf13/cobra"
9-)
10-
11-func newAutorouteCmd() *cobra.Command {
12- cmd := &cobra.Command{
13- Use: "autoroute",
14- Short: "Manage auto-route settings",
15- }
16- cmd.AddCommand(newAutorouteAllowCmd())
17- return cmd
18-}
19-
20-func newAutorouteAllowCmd() *cobra.Command {
21- cmd := &cobra.Command{
22- Use: "allow",
23- Short: "Manage auto-route allowed domains",
24- }
25- cmd.AddCommand(newAutorouteAllowAddCmd())
26- cmd.AddCommand(newAutorouteAllowListCmd())
27- cmd.AddCommand(newAutorouteAllowRemoveCmd())
28- return cmd
29-}
30-
31-func newAutorouteAllowAddCmd() *cobra.Command {
32- return &cobra.Command{
33- Use: "add <pattern>",
34- Short: "Allow an auto-route domain pattern",
35- Args: cobra.ExactArgs(1),
36- RunE: func(cmd *cobra.Command, args []string) error {
37- return runAutorouteAllowAdd(cmd.Context(), args, cmd.OutOrStdout())
38- },
39- }
40-}
41-
42-func newAutorouteAllowListCmd() *cobra.Command {
43- var jsonOut bool
44- cmd := &cobra.Command{
45- Use: "list",
46- Short: "List auto-route allowed domains",
47- RunE: func(cmd *cobra.Command, args []string) error {
48- return runAutorouteAllowList(cmd.Context(), cmd.OutOrStdout(), jsonOut)
49- },
50- }
51- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
52- return cmd
53-}
54-
55-func newAutorouteAllowRemoveCmd() *cobra.Command {
56- return &cobra.Command{
57- Use: "remove <pattern>",
58- Short: "Remove an auto-route domain pattern",
59- Args: cobra.ExactArgs(1),
60- RunE: func(cmd *cobra.Command, args []string) error {
61- return runAutorouteAllowRemove(cmd.Context(), args, cmd.OutOrStdout())
62- },
63- }
64-}
65-
66-func runAutorouteAllowAdd(ctx context.Context, args []string, out io.Writer) error {
67- handle, err := resolveControlPlane(configPath)
68- if err != nil {
69- return err
70- }
71- defer handle.close()
72- if err := handle.plane.AddAutoRouteAllowedDomain(ctx, args[0]); err != nil {
73- return fmt.Errorf("failed to add auto-route allowed domain: %w", err)
74- }
75- return cliWriteLine(out, cliRenderSuccess("Allowed domain added"))
76-}
77-
78-func runAutorouteAllowList(ctx context.Context, out io.Writer, jsonOut bool) error {
79- handle, err := resolveControlPlane(configPath)
80- if err != nil {
81- return err
82- }
83- defer handle.close()
84- domains, err := handle.plane.GetAutoRouteAllowedDomains(ctx)
85- if err != nil {
86- return fmt.Errorf("failed to list auto-route allowed domains: %w", err)
87- }
88- if jsonOut {
89- return writeJSON(out, map[string]any{"domains": domains})
90- }
91- if len(domains) == 0 {
92- return cliWriteLine(out, cliRenderMuted("No allowed domains configured"))
93- }
94- for _, domain := range domains {
95- if err := cliWriteLine(out, domain); err != nil {
96- return err
97- }
98- }
99- return nil
100-}
101-
102-func runAutorouteAllowRemove(ctx context.Context, args []string, out io.Writer) error {
103- handle, err := resolveControlPlane(configPath)
104- if err != nil {
105- return err
106- }
107- defer handle.close()
108- if err := handle.plane.RemoveAutoRouteAllowedDomain(ctx, args[0]); err != nil {
109- return fmt.Errorf("failed to remove auto-route allowed domain: %w", err)
110- }
111- return cliWriteLine(out, cliRenderSuccess("Allowed domain removed"))
112-}
M internal/adapters/in/cli/backup.go +157 -230
@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66 "io"
7- "os"
87 "text/tabwriter"
98 "time"
109
@@ -14,54 +13,35 @@ import (
1413 "github.com/bnema/gordon/pkg/bytesize"
1514 )
1615
17-var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18- if domainName != "" {
19- return resolveControlPlaneForRouteDomain(ctx, domainName)
20- }
21- return resolveControlPlane(configPath)
22-}
23-
24-// newBackupCmd creates the backup command group.
16+// newBackupCmd creates the backup command group. Backups are identified by
17+// app, service, and the declared database or volume: a domain is never an
18+// identity.
2519 func newBackupCmd() *cobra.Command {
2620 cmd := &cobra.Command{
2721 Use: "backups",
2822 Aliases: []string{"backup"},
29- Short: "Manage backups",
30- Long: `Manage backups.
23+ Short: "Manage app backups",
24+ Long: `Manage app backups.
3125
32-Runs locally via in-process services by default, or against a remote Gordon
33-instance when --remote targeting is configured.`,
26+Backup targets come from the app manifest: a service declares its databases
27+and volumes, and which of them are backed up. Runs locally through the daemon's
28+authenticated Unix socket, or against a remote Gordon instance when --remote
29+targeting is configured.`,
3430 }
3531
36- cmd.AddCommand(newBackupDatabasesCmd())
37- cmd.AddCommand(newBackupVolumesCmd())
38-
39- // Compatibility aliases for the original database backup command shape.
4032 cmd.AddCommand(newBackupListCmd())
4133 cmd.AddCommand(newBackupRunCmd())
42- cmd.AddCommand(newBackupDetectCmd())
4334 cmd.AddCommand(newBackupStatusCmd())
35+ cmd.AddCommand(newBackupVolumeCmd())
4436
4537 return cmd
4638 }
4739
48-func newBackupDatabasesCmd() *cobra.Command {
49- cmd := &cobra.Command{
50- Use: "databases",
51- Aliases: []string{"database", "db"},
52- Short: "Manage database backups",
53- }
54- cmd.AddCommand(newBackupListCmd())
55- cmd.AddCommand(newBackupRunCmd())
56- cmd.AddCommand(newBackupDetectCmd())
57- cmd.AddCommand(newBackupStatusCmd())
58- return cmd
59-}
60-
61-func newBackupVolumesCmd() *cobra.Command {
40+// newBackupVolumeCmd creates the `backup volume` group.
41+func newBackupVolumeCmd() *cobra.Command {
6242 cmd := &cobra.Command{
63- Use: "volumes",
64- Short: "Manage volume backups",
43+ Use: "volume",
44+ Short: "Manage app volume backups",
6545 }
6646 cmd.AddCommand(newVolumeBackupListCmd())
6747 cmd.AddCommand(newVolumeBackupRunCmd())
@@ -73,44 +53,36 @@ func newBackupListCmd() *cobra.Command {
7353 var jsonOut bool
7454
7555 cmd := &cobra.Command{
76- Use: "list [domain]",
77- Short: "List backups",
78- Long: cliRenderMuted("List backups for all domains or a specific domain."),
56+ Use: "list [APP]",
57+ Short: "List database backups",
58+ Long: cliRenderMuted("List stored backups for all apps, or for one app."),
7959 Args: cobra.MaximumNArgs(1),
8060 RunE: func(cmd *cobra.Command, args []string) error {
81- domainName := ""
61+ app := ""
8262 if len(args) == 1 {
83- domainName = args[0]
84- }
85-
86- var (
87- handle *controlPlaneHandle
88- err error
89- )
90- if domainName != "" {
91- handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName)
92- } else {
93- handle, err = resolveControlPlane(configPath)
63+ app = args[0]
9464 }
65+ handle, err := resolveControlPlane(cliConfigPath)
9566 if err != nil {
9667 return err
9768 }
9869 defer handle.close()
99-
100- jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
101- if err != nil {
102- return fmt.Errorf("failed to list backups: %w", err)
103- }
104-
105- return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
70+ return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
10671 },
10772 }
10873
10974 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
110-
11175 return cmd
11276 }
11377
78+func runBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
79+ jobs, err := plane.ListBackups(ctx, app)
80+ if err != nil {
81+ return fmt.Errorf("failed to list backups: %w", err)
82+ }
83+ return printBackupJobs(out, jobs, jsonOut)
84+}
85+
11486 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
11587 if len(jobs) == 0 {
11688 if jsonOut {
@@ -127,106 +99,192 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
12799 return err
128100 }
129101 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
130- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
102+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tDATABASE\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
131103 return err
132104 }
133-
134105 for _, job := range jobs {
135- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
106+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
107+ job.App, job.Service, job.Database, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
136108 return err
137109 }
138110 }
139111 return w.Flush()
140112 }
141113
142-func newVolumeBackupListCmd() *cobra.Command {
114+func newBackupRunCmd() *cobra.Command {
115+ var service string
116+ var database string
143117 var jsonOut bool
144118
145119 cmd := &cobra.Command{
146- Use: "list [domain]",
147- Short: "List volume backups",
148- Args: cobra.MaximumNArgs(1),
120+ Use: "run APP",
121+ Short: "Run a database backup now",
122+ Long: cliRenderMuted(`Run the declared database backup of one app service.
123+
124+--service and --database select the target. Omitting a selector is only
125+allowed when exactly one compatible target exists.`),
126+ Args: cobra.ExactArgs(1),
149127 RunE: func(cmd *cobra.Command, args []string) error {
150- domainName := ""
151- if len(args) == 1 {
152- domainName = args[0]
153- }
154- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
128+ handle, err := resolveControlPlane(cliConfigPath)
155129 if err != nil {
156130 return err
157131 }
158132 defer handle.close()
133+ return runBackupRun(cmd.Context(), handle.plane, args[0], service, database, cmd.OutOrStdout(), jsonOut)
134+ },
135+ }
136+
137+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the database")
138+ cmd.Flags().StringVar(&database, "database", "", "Declared database name (optional)")
139+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
140+ return cmd
141+}
142+
143+func runBackupRun(ctx context.Context, plane ControlPlane, app, service, database string, out io.Writer, jsonOut bool) error {
144+ result, err := plane.RunBackup(ctx, app, service, database)
145+ if err != nil {
146+ return fmt.Errorf("failed to run backup: %w", err)
147+ }
148+ if result.Backup == nil {
149+ return fmt.Errorf("backup run completed without a backup payload")
150+ }
151+ if jsonOut {
152+ return writeJSON(out, result)
153+ }
154+ return printBackupJobs(out, []dto.BackupJob{*result.Backup}, false)
155+}
159156
160- jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
157+func newBackupStatusCmd() *cobra.Command {
158+ var jsonOut bool
159+ cmd := &cobra.Command{
160+ Use: "status",
161+ Short: "Show database backup status",
162+ Long: cliRenderMuted("Show stored backups plus declared targets that have no completed backup yet."),
163+ RunE: func(cmd *cobra.Command, args []string) error {
164+ handle, err := resolveControlPlane(cliConfigPath)
161165 if err != nil {
162- return fmt.Errorf("failed to list volume backups: %w", err)
166+ return err
163167 }
164- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
168+ defer handle.close()
169+ return runBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
165170 },
166171 }
167172 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168173 return cmd
169174 }
170175
171-func newVolumeBackupRunCmd() *cobra.Command {
172- var volumeName string
176+func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
177+ jobs, err := plane.BackupStatus(ctx)
178+ if err != nil {
179+ return fmt.Errorf("failed to get backup status: %w", err)
180+ }
181+ return printBackupJobs(out, jobs, jsonOut)
182+}
183+
184+func newVolumeBackupListCmd() *cobra.Command {
173185 var jsonOut bool
174186
175187 cmd := &cobra.Command{
176- Use: "run [domain]",
177- Short: "Run volume backups now",
188+ Use: "list [APP]",
189+ Short: "List volume backups",
178190 Args: cobra.MaximumNArgs(1),
179191 RunE: func(cmd *cobra.Command, args []string) error {
180- domainName := ""
192+ app := ""
181193 if len(args) == 1 {
182- domainName = args[0]
194+ app = args[0]
183195 }
184- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
196+ handle, err := resolveControlPlane(cliConfigPath)
185197 if err != nil {
186198 return err
187199 }
188200 defer handle.close()
201+ return runVolumeBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
202+ },
203+ }
204+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
205+ return cmd
206+}
189207
190- result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
208+func runVolumeBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
209+ jobs, err := plane.ListVolumeBackups(ctx, app)
210+ if err != nil {
211+ return fmt.Errorf("failed to list volume backups: %w", err)
212+ }
213+ return printVolumeBackupJobs(out, jobs, jsonOut)
214+}
215+
216+func newVolumeBackupRunCmd() *cobra.Command {
217+ var service string
218+ var volume string
219+ var jsonOut bool
220+
221+ cmd := &cobra.Command{
222+ Use: "run APP",
223+ Short: "Run a volume backup now",
224+ Long: cliRenderMuted(`Run the declared volume backup of one app service.
225+
226+--service and --volume select the target. Omitting a selector is only
227+allowed when exactly one compatible target exists.`),
228+ Args: cobra.ExactArgs(1),
229+ RunE: func(cmd *cobra.Command, args []string) error {
230+ handle, err := resolveControlPlane(cliConfigPath)
191231 if err != nil {
192- if result != nil && len(result.Backups) > 0 {
193- if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
194- return printErr
195- }
196- }
197- return fmt.Errorf("failed to run volume backups: %w", err)
232+ return err
198233 }
199- return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
234+ defer handle.close()
235+ return runVolumeBackupRun(cmd.Context(), handle.plane, args[0], service, volume, cmd.OutOrStdout(), jsonOut)
200236 },
201237 }
202- cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
238+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the volume")
239+ cmd.Flags().StringVar(&volume, "volume", "", "Declared volume name (optional)")
203240 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204241 return cmd
205242 }
206243
244+func runVolumeBackupRun(ctx context.Context, plane ControlPlane, app, service, volume string, out io.Writer, jsonOut bool) error {
245+ result, err := plane.RunVolumeBackups(ctx, app, service, volume)
246+ if err != nil {
247+ // Partial results are reported before the error: an operator must
248+ // see which volumes completed.
249+ if result != nil && len(result.Backups) > 0 {
250+ if printErr := printVolumeBackupJobs(out, result.Backups, jsonOut); printErr != nil {
251+ return printErr
252+ }
253+ }
254+ return fmt.Errorf("failed to run volume backup: %w", err)
255+ }
256+ if jsonOut {
257+ return writeJSON(out, result)
258+ }
259+ return printVolumeBackupJobs(out, result.Backups, false)
260+}
261+
207262 func newVolumeBackupStatusCmd() *cobra.Command {
208263 var jsonOut bool
209264 cmd := &cobra.Command{
210265 Use: "status",
211266 Short: "Show volume backup status",
212267 RunE: func(cmd *cobra.Command, args []string) error {
213- handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
268+ handle, err := resolveControlPlane(cliConfigPath)
214269 if err != nil {
215270 return err
216271 }
217272 defer handle.close()
218-
219- jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
220- if err != nil {
221- return fmt.Errorf("failed to get volume backup status: %w", err)
222- }
223- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
273+ return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
224274 },
225275 }
226276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227277 return cmd
228278 }
229279
280+func runVolumeBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
281+ jobs, err := plane.VolumeBackupStatus(ctx)
282+ if err != nil {
283+ return fmt.Errorf("failed to get volume backup status: %w", err)
284+ }
285+ return printVolumeBackupJobs(out, jobs, jsonOut)
286+}
287+
230288 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231289 if len(jobs) == 0 {
232290 if jsonOut {
@@ -241,150 +299,19 @@ func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bo
241299 return err
242300 }
243301 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
244- if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
302+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tVOLUME\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
245303 return err
246304 }
247305 for _, job := range jobs {
248- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", job.Domain, job.VolumeName, job.ContainerName, job.Compression, job.Status, formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
306+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
307+ job.App, job.Service, job.VolumeName, job.Compression, job.Status,
308+ formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
249309 return err
250310 }
251311 }
252312 return w.Flush()
253313 }
254314
255-func newBackupRunCmd() *cobra.Command {
256- var dbName string
257-
258- cmd := &cobra.Command{
259- Use: "run <domain>",
260- Short: "Run backup now",
261- Args: cobra.ExactArgs(1),
262- RunE: func(cmd *cobra.Command, args []string) error {
263- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
264- if err != nil {
265- return err
266- }
267- defer handle.close()
268-
269- result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
270- if err != nil {
271- return fmt.Errorf("failed to run backup: %w", err)
272- }
273-
274- if result.Backup == nil {
275- return fmt.Errorf("backup run completed without backup payload")
276- }
277- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
278- return err
279- }
280-
281- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
282- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
283- return err
284- }
285- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
286- return err
287- }
288- if err := w.Flush(); err != nil {
289- return err
290- }
291- return nil
292- },
293- }
294-
295- cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)")
296- return cmd
297-}
298-
299-func newBackupDetectCmd() *cobra.Command {
300- return &cobra.Command{
301- Use: "detect <domain>",
302- Short: "Detect databases for domain",
303- Args: cobra.ExactArgs(1),
304- RunE: func(cmd *cobra.Command, args []string) error {
305- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
306- if err != nil {
307- return err
308- }
309- defer handle.close()
310-
311- dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
312- if err != nil {
313- return fmt.Errorf("failed to detect databases: %w", err)
314- }
315-
316- if len(dbs) == 0 {
317- if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
318- return err
319- }
320- return nil
321- }
322- if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
323- return err
324- }
325- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
326- if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
327- return err
328- }
329-
330- for _, db := range dbs {
331- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName); err != nil {
332- return err
333- }
334- }
335- if err := w.Flush(); err != nil {
336- return err
337- }
338-
339- return nil
340- },
341- }
342-}
343-
344-func newBackupStatusCmd() *cobra.Command {
345- return &cobra.Command{
346- Use: "status",
347- Short: "Show backup status",
348- RunE: func(cmd *cobra.Command, args []string) error {
349- handle, err := resolveControlPlane(configPath)
350- if err != nil {
351- return err
352- }
353- defer handle.close()
354-
355- jobs, err := handle.plane.BackupStatus(cmd.Context())
356- if err != nil {
357- return fmt.Errorf("failed to get backup status: %w", err)
358- }
359-
360- if len(jobs) == 0 {
361- if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
362- return err
363- }
364- return nil
365- }
366- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
367- return err
368- }
369- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
370- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
371- return err
372- }
373-
374- for _, job := range jobs {
375- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt)); err != nil {
376- return err
377- }
378- }
379- if err := w.Flush(); err != nil {
380- return err
381- }
382-
383- return nil
384- },
385- }
386-}
387-
388315 func formatBackupTime(t *time.Time) string {
389316 if t == nil {
390317 return ""
M internal/adapters/in/cli/backup_volume_test.go +13 -63
@@ -13,86 +13,36 @@ import (
1313 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1414 )
1515
16-func withBackupControlPlane(t *testing.T, plane ControlPlane) {
16+// backupRunPlane builds the control plane double the runner functions
17+// take directly: no resolver indirection is needed.
18+func backupRunPlane(t *testing.T) *climocks.MockControlPlane {
1719 t.Helper()
18- old := backupResolveControlPlane
19- backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) {
20- return &controlPlaneHandle{plane: plane}, nil
21- }
22- t.Cleanup(func() { backupResolveControlPlane = old })
23-}
24-
25-func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26- plane := climocks.NewMockControlPlane(t)
27- old := backupResolveControlPlane
28- var gotDomain string
29- backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30- gotDomain = domainName
31- return &controlPlaneHandle{plane: plane}, nil
32- }
33- t.Cleanup(func() { backupResolveControlPlane = old })
34-
35- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36-
37- cmd := newVolumeBackupRunCmd()
38- cmd.SetArgs([]string{"app.example.com"})
39-
40- require.NoError(t, cmd.ExecuteContext(context.Background()))
41- require.Equal(t, "app.example.com", gotDomain)
42-}
43-
44-func TestVolumeBackupRun_JSONFlag(t *testing.T) {
45- plane := climocks.NewMockControlPlane(t)
46- withBackupControlPlane(t, plane)
47-
48- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
49- Status: "ok",
50- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
51- }, nil)
52-
53- cmd := newVolumeBackupRunCmd()
54- var out bytes.Buffer
55- cmd.SetOut(&out)
56- cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
57-
58- require.NoError(t, cmd.ExecuteContext(context.Background()))
59- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
20+ return climocks.NewMockControlPlane(t)
6021 }
6122
6223 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
63- plane := climocks.NewMockControlPlane(t)
64- withBackupControlPlane(t, plane)
24+ plane := backupRunPlane(t)
6525
66- plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)
26+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "running"}}, nil)
6727
68- cmd := newVolumeBackupStatusCmd()
6928 var out bytes.Buffer
70- cmd.SetOut(&out)
71- cmd.SetArgs([]string{"--json"})
72-
73- require.NoError(t, cmd.ExecuteContext(context.Background()))
74- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
29+ require.NoError(t, runVolumeBackupStatus(context.Background(), plane, &out, true))
30+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"running","size_bytes":0}]`, out.String())
7531 }
7632
7733 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
78- plane := climocks.NewMockControlPlane(t)
79- withBackupControlPlane(t, plane)
34+ plane := backupRunPlane(t)
8035
8136 runErr := errors.New("one backup failed")
82- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{
37+ plane.EXPECT().RunVolumeBackups(mock.Anything, "shop", "api", "data").Return(&dto.VolumeBackupRunResponse{
8338 Status: "partial",
84- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
39+ Backups: []dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
8540 Error: runErr.Error(),
8641 }, runErr)
8742
88- cmd := newVolumeBackupRunCmd()
89- cmd.SilenceUsage = true
9043 var out bytes.Buffer
91- cmd.SetOut(&out)
92- cmd.SetArgs([]string{"app.example.com", "--json"})
93-
94- err := cmd.ExecuteContext(context.Background())
44+ err := runVolumeBackupRun(context.Background(), plane, "shop", "api", "data", &out, true)
9545 require.Error(t, err)
9646 require.ErrorIs(t, err, runErr)
97- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
47+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"completed","size_bytes":0}]`, out.String())
9848 }
D internal/adapters/in/cli/bootstrap.go +0 -136
@@ -1,136 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8- "strings"
9-
10- "github.com/spf13/cobra"
11-
12- "github.com/bnema/gordon/internal/adapters/dto"
13-)
14-
15-func newBootstrapCmd() *cobra.Command {
16- var attachments []string
17- var envPairs []string
18- var attachmentEnvPairs []string
19- var configPath string
20-
21- cmd := &cobra.Command{
22- Use: "bootstrap <domain> <image>",
23- Short: "Create a route, attachments, and secrets together",
24- Args: cobra.ExactArgs(2),
25- RunE: func(cmd *cobra.Command, args []string) error {
26- req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs)
27- if err != nil {
28- return err
29- }
30- return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout())
31- },
32- }
33-
34- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
35- cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
36- cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
37- cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
38-
39- return cmd
40-}
41-
42-func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) {
43- req := dto.BootstrapRequest{
44- Domain: args[0],
45- Image: args[1],
46- Attachments: append([]string(nil), attachments...),
47- Env: map[string]string{},
48- AttachmentEnv: map[string]map[string]string{},
49- }
50-
51- for _, pair := range envPairs {
52- parts := strings.SplitN(pair, "=", 2)
53- if len(parts) != 2 {
54- return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
55- }
56- req.Env[parts[0]] = parts[1]
57- }
58-
59- for _, pair := range attachmentEnvPairs {
60- serviceAndKV := strings.SplitN(pair, ":", 2)
61- if len(serviceAndKV) != 2 {
62- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
63- }
64- keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
65- if len(keyValue) != 2 {
66- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
67- }
68- service := serviceAndKV[0]
69- if req.AttachmentEnv[service] == nil {
70- req.AttachmentEnv[service] = map[string]string{}
71- }
72- req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
73- }
74-
75- if len(req.Env) == 0 {
76- req.Env = nil
77- }
78- if len(req.AttachmentEnv) == 0 {
79- req.AttachmentEnv = nil
80- }
81-
82- return req, nil
83-}
84-
85-func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error {
86- handle, err := resolveControlPlane(configPath)
87- if err != nil {
88- return err
89- }
90- defer handle.close()
91-
92- resp, err := handle.plane.Bootstrap(ctx, req)
93- if resp != nil {
94- if writeErr := printBootstrapSummary(out, resp); writeErr != nil {
95- return writeErr
96- }
97- }
98- if err != nil {
99- return fmt.Errorf("failed to bootstrap: %w", err)
100- }
101- if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
102- return err
103- }
104- nextImage := req.Image
105- if resp != nil && resp.Image != "" {
106- nextImage = resp.Image
107- }
108- return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage))
109-}
110-
111-func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error {
112- if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
113- return err
114- }
115- if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
116- return err
117- }
118- if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
119- return err
120- }
121- for _, step := range resp.Steps {
122- if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
123- return err
124- }
125- }
126- if len(resp.Warnings) > 0 {
127- sorted := append([]string(nil), resp.Warnings...)
128- sort.Strings(sorted)
129- for _, warning := range sorted {
130- if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
131- return err
132- }
133- }
134- }
135- return nil
136-}
M internal/adapters/in/cli/ca.go +1 -1
@@ -96,7 +96,7 @@ func newCAInfoCmd() *cobra.Command {
9696 }
9797
9898 func resolveCADataDir() (string, error) {
99- local, err := GetLocalServices(configPath)
99+ local, err := GetLocalServices(cliConfigPath)
100100 if err != nil {
101101 return "", err
102102 }
M internal/adapters/in/cli/config.go +1 -37
@@ -37,7 +37,7 @@ Examples:
3737 gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
3838 RunE: func(cmd *cobra.Command, _ []string) error {
3939 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)
40+ handle, err := resolveControlPlane(cliConfigPath)
4141 if err != nil {
4242 return err
4343 }
@@ -72,9 +72,6 @@ func renderConfigTable(out io.Writer, config *remote.Config) error {
7272 if err := renderConfigSummary(out, config); err != nil {
7373 return err
7474 }
75- if err := renderConfigRoutes(out, config); err != nil {
76- return err
77- }
7875 return renderConfigExternalRoutes(out, config)
7976 }
8077
@@ -99,9 +96,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
9996 return err
10097 }
10198 }
102- if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
103- return err
104- }
10599 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
106100 return err
107101 }
@@ -113,36 +107,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
113107 return nil
114108 }
115109
116-func renderConfigRoutes(out io.Writer, config *remote.Config) error {
117- if err := cliWriteLine(out, ""); err != nil {
118- return err
119- }
120- if len(config.Routes) == 0 {
121- if err := cliWriteLine(out, cliRenderMuted("No routes configured")); err != nil {
122- return err
123- }
124- } else {
125- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
126- return err
127- }
128- rows := make([][]string, 0, len(config.Routes))
129- for _, route := range config.Routes {
130- rows = append(rows, []string{route.Domain, route.Image})
131- }
132- table := components.NewTable(
133- components.WithColumns([]components.TableColumn{
134- {Title: "Domain", Width: 30},
135- {Title: "Image", Width: 45},
136- }),
137- components.WithRows(rows),
138- )
139- if err := cliWriteLine(out, table.View()); err != nil {
140- return err
141- }
142- }
143- return nil
144-}
145-
146110 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
147111 if err := cliWriteLine(out, ""); err != nil {
148112 return err
M internal/adapters/in/cli/controlplane.go +21 -36
@@ -9,35 +9,26 @@ import (
99 )
1010
1111 // ControlPlane defines command operations available to CLI execution paths.
12-//
13-// Remote implementations call admin HTTP APIs.
14-// Local implementations call services directly in-process.
12+// Both explicit remote and local-daemon implementations call admin HTTP APIs.
1513 type ControlPlane interface {
16- ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
17- GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
18- GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19- FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)
20- AddRoute(ctx context.Context, route domain.Route) error
21- UpdateRoute(ctx context.Context, route domain.Route) error
22- RemoveRoute(ctx context.Context, routeDomain string) error
23- Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)
24-
25- ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
14+ // App lifecycle and reads. App mutations are daemon-owned for both
15+ // the explicit remote and the owner-only local admin socket.
16+ ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)
17+ ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error)
18+ ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error)
19+ DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error)
20+ DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
21+ StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
22+ StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
23+ RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)
24+ RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
25+ OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
26+ SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
27+ DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
28+
29+ ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
2630 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
2731 DeleteSecret(ctx context.Context, secretDomain, key string) error
28- SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error
29- DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error
30-
31- GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
32- GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error)
33- ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error)
34- CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)
35- FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
36- AddAttachment(ctx context.Context, domainOrGroup, image string) error
37- RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
38- GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
39- AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
40- RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
4132
4233 GetStatus(ctx context.Context) (*remote.Status, error)
4334 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
@@ -45,23 +36,17 @@ type ControlPlane interface {
4536 Reload(ctx context.Context) error
4637 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4738 GetConfig(ctx context.Context) (*remote.Config, error)
48- DeployIntent(ctx context.Context, imageName string) error
49- Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
50- Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
5139 ListTags(ctx context.Context, repository string) ([]string, error)
5240
53- ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)
41+ ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error)
5442 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
55- RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
56- DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
57- ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
43+ RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error)
44+ ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)
5845 VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)
59- RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error)
46+ RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error)
6047
6148 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
62- GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
6349 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
64- StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
6550
6651 ListVolumes(ctx context.Context) ([]dto.Volume, error)
6752 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
D internal/adapters/in/cli/controlplane_local.go +0 -872
@@ -1,872 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "sort"
8- "time"
9-
10- "github.com/bnema/gordon/internal/adapters/dto"
11- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
12- "github.com/bnema/gordon/internal/app"
13- "github.com/bnema/gordon/internal/boundaries/in"
14- "github.com/bnema/gordon/internal/domain"
15- "github.com/bnema/gordon/internal/usecase/config"
16-)
17-
18-type localControlPlane struct {
19- configSvc in.ConfigService
20- secretSvc in.SecretService
21- containerSvc in.ContainerService
22- backupSvc in.BackupService
23- volumeBackupSvc in.VolumeBackupService
24- registrySvc in.RegistryService
25- deployCoord in.DeployCoordinator
26- healthSvc in.HealthService
27- logSvc in.LogService
28- volumeSvc in.VolumeService
29- publicTLSSvc in.PublicTLSService
30-}
31-
32-func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
33- if kernel == nil {
34- return &localControlPlane{}
35- }
36-
37- registrySvc := kernel.Registry()
38- var deployCoord in.DeployCoordinator
39- if registrySvc != nil {
40- if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
41- deployCoord = coordinator
42- }
43- }
44-
45- return &localControlPlane{
46- configSvc: kernel.Config(),
47- secretSvc: kernel.Secrets(),
48- containerSvc: kernel.Container(),
49- backupSvc: kernel.Backup(),
50- volumeBackupSvc: kernel.VolumeBackup(),
51- registrySvc: registrySvc,
52- deployCoord: deployCoord,
53- healthSvc: kernel.Health(),
54- logSvc: kernel.Logs(),
55- volumeSvc: kernel.Volumes(),
56- publicTLSSvc: kernel.PublicTLS(),
57- }
58-}
59-
60-func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
61- if l.containerSvc != nil {
62- if err := l.containerSvc.SyncContainers(ctx); err != nil {
63- return nil, err
64- }
65- detailed := l.containerSvc.ListRoutesWithDetails(ctx)
66- if l.configSvc == nil {
67- return toRemoteRouteInfos(detailed), nil
68- }
69- return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil
70- }
71-
72- if l.configSvc == nil {
73- return nil, fmt.Errorf("local config service unavailable")
74- }
75-
76- routes := l.configSvc.GetRoutes(ctx)
77- infos := make([]remote.RouteInfo, 0, len(routes))
78- for _, route := range routes {
79- infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
80- }
81- return infos, nil
82-}
83-
84-func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
85- if l.healthSvc == nil {
86- return map[string]*remote.RouteHealth{}, nil
87- }
88-
89- health := l.healthSvc.CheckAllRoutes(ctx)
90- result := make(map[string]*remote.RouteHealth, len(health))
91- for domainName, h := range health {
92- if h == nil {
93- continue
94- }
95- result[domainName] = &remote.RouteHealth{
96- ContainerStatus: h.ContainerStatus,
97- HTTPStatus: h.HTTPStatus,
98- ResponseTimeMs: h.ResponseTimeMs,
99- Healthy: h.Healthy,
100- Error: h.Error,
101- }
102- }
103- return result, nil
104-}
105-
106-func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
107- if l.configSvc == nil {
108- return nil, fmt.Errorf("local config service unavailable")
109- }
110- return l.configSvc.GetRoute(ctx, routeDomain)
111-}
112-
113-func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
114- if l.configSvc == nil {
115- return nil, fmt.Errorf("local config service unavailable")
116- }
117- return l.configSvc.FindRoutesByImage(ctx, imageName), nil
118-}
119-
120-func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
121- if l.configSvc == nil {
122- return fmt.Errorf("local config service unavailable")
123- }
124- return l.configSvc.AddRoute(ctx, route)
125-}
126-
127-func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
128- if l.configSvc == nil {
129- return fmt.Errorf("local config service unavailable")
130- }
131- return l.configSvc.UpdateRoute(ctx, route)
132-}
133-
134-func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
135- _, err := l.RemoveRouteWithCleanup(ctx, routeDomain)
136- return err
137-}
138-
139-func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
140- if l.configSvc == nil {
141- return nil, fmt.Errorf("local config service unavailable")
142- }
143- if err := l.configSvc.RemoveRoute(ctx, routeDomain); err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
144- return nil, fmt.Errorf("remove route: %w", err)
145- }
146- resp := &dto.RouteDeleteResponse{Status: "removed"}
147- if l.containerSvc == nil {
148- return resp, nil
149- }
150- report, err := l.containerSvc.ReconcileRemovedRoute(ctx, routeDomain)
151- if err != nil {
152- return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err)
153- }
154- resp.Cleanup = dto.CleanupReportFromDomain(report)
155- return resp, nil
156-}
157-
158-func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
159- previewer, ok := any(l.containerSvc).(interface {
160- PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
161- })
162- if !ok {
163- return nil, fmt.Errorf("route cleanup preview unavailable")
164- }
165-
166- report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
167- if err != nil {
168- return nil, err
169- }
170- if report == nil {
171- return nil, nil
172- }
173- if l.volumeSvc == nil {
174- return report, nil
175- }
176-
177- volumes, err := l.ListVolumes(ctx)
178- if err != nil {
179- return nil, fmt.Errorf("list volumes: %w", err)
180- }
181- if len(volumes) == 0 {
182- return report, nil
183- }
184-
185- scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments)
186- for _, volume := range volumesForRouteDiagnosis(volumes, scope) {
187- report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{
188- Name: volume.Name,
189- Reason: "volume preserved for explicit cleanup review",
190- })
191- }
192- return report, nil
193-}
194-
195-func (l *localControlPlane) routeCleanupVolumePrefix() string {
196- const defaultPrefix = "gordon"
197- if l.configSvc == nil {
198- return defaultPrefix
199- }
200- if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
201- _, prefix, _ := volumeCfg.GetVolumeConfig()
202- if prefix != "" {
203- return prefix
204- }
205- }
206- return defaultPrefix
207-}
208-
209-func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
210- registryDomain := ""
211- if l.configSvc != nil {
212- registryDomain = l.configSvc.GetRegistryDomain()
213- }
214- normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
215- if err != nil {
216- return nil, fmt.Errorf("invalid image: %w", err)
217- }
218-
219- resp := &dto.BootstrapResponse{
220- Domain: req.Domain,
221- Image: normalizedImage,
222- Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
223- }
224-
225- if l.configSvc == nil {
226- return resp, fmt.Errorf("local config service unavailable")
227- }
228- needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
229- if needsSecrets && l.secretSvc == nil {
230- return resp, fmt.Errorf("local secret service unavailable")
231- }
232-
233- addStep := func(name, status string) {
234- resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
235- }
236-
237- err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
238- switch err {
239- case nil:
240- addStep("route", "configured")
241- default:
242- addStep("route", "failed")
243- return resp, err
244- }
245-
246- if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
247- return resp, err
248- }
249-
250- if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
251- return resp, err
252- }
253-
254- if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
255- return resp, err
256- }
257-
258- return resp, nil
259-}
260-
261-func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
262- for _, attachment := range req.Attachments {
263- err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
264- if err == nil {
265- addStep("attachment:"+attachment, "created")
266- continue
267- }
268- if errors.Is(err, domain.ErrAttachmentExists) {
269- addStep("attachment:"+attachment, "noop")
270- continue
271- }
272- addStep("attachment:"+attachment, "failed")
273- return err
274- }
275-
276- return nil
277-}
278-
279-func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
280- if len(req.Env) == 0 {
281- return nil
282- }
283-
284- if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
285- addStep("env", "failed")
286- return err
287- }
288-
289- addStep("env", "updated")
290- return nil
291-}
292-
293-func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
294- for service, env := range req.AttachmentEnv {
295- if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
296- addStep("attachment_env:"+service, "failed")
297- return err
298- }
299- addStep("attachment_env:"+service, "updated")
300- }
301-
302- return nil
303-}
304-
305-func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
306- if l.secretSvc == nil {
307- return nil, fmt.Errorf("local secret service unavailable")
308- }
309-
310- keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
311- if err != nil {
312- return nil, fmt.Errorf("list secret keys with attachments: %w", err)
313- }
314-
315- result := &remote.SecretsListResult{
316- Domain: secretDomain,
317- Keys: keys,
318- }
319- for _, att := range attachments {
320- result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
321- Service: att.Service,
322- Keys: att.Keys,
323- })
324- }
325-
326- return result, nil
327-}
328-
329-func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
330- if l.secretSvc == nil {
331- return fmt.Errorf("local secret service unavailable")
332- }
333- return l.secretSvc.Set(ctx, secretDomain, secrets)
334-}
335-
336-func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
337- if l.secretSvc == nil {
338- return fmt.Errorf("local secret service unavailable")
339- }
340- return l.secretSvc.Delete(ctx, secretDomain, key)
341-}
342-
343-func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
344- if l.secretSvc == nil {
345- return fmt.Errorf("local secret service unavailable")
346- }
347- return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
348-}
349-
350-func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
351- if l.secretSvc == nil {
352- return fmt.Errorf("local secret service unavailable")
353- }
354- return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
355-}
356-
357-func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
358- if l.containerSvc == nil {
359- return nil, fmt.Errorf("local container service unavailable")
360- }
361- attachments, err := l.containerSvc.ListOrphanedAttachments(ctx)
362- if err != nil {
363- return nil, fmt.Errorf("list orphaned attachments: %w", err)
364- }
365- return attachments, nil
366-}
367-
368-func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
369- if l.containerSvc == nil {
370- return nil, fmt.Errorf("local container service unavailable")
371- }
372- report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop)
373- if err != nil {
374- return nil, fmt.Errorf("cleanup orphaned attachments: %w", err)
375- }
376- return report, nil
377-}
378-
379-func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
380- if l.configSvc == nil {
381- return nil, fmt.Errorf("local config service unavailable")
382- }
383- return l.configSvc.GetAllAttachments(ctx), nil
384-}
385-
386-func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
387- if l.configSvc == nil {
388- return nil, fmt.Errorf("local config service unavailable")
389- }
390- return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
391-}
392-
393-func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
394- if l.configSvc == nil {
395- return nil, fmt.Errorf("local config service unavailable")
396- }
397- return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil
398-}
399-
400-func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
401- if l.configSvc == nil {
402- return fmt.Errorf("local config service unavailable")
403- }
404- return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
405-}
406-
407-func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
408- if l.configSvc == nil {
409- return fmt.Errorf("local config service unavailable")
410- }
411- return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
412-}
413-
414-func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
415- if l.configSvc == nil {
416- return nil, fmt.Errorf("local config service unavailable")
417- }
418- return l.configSvc.GetAutoRouteAllowedDomains(ctx)
419-}
420-
421-func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
422- if l.configSvc == nil {
423- return fmt.Errorf("local config service unavailable")
424- }
425- return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
426-}
427-
428-func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
429- if l.configSvc == nil {
430- return fmt.Errorf("local config service unavailable")
431- }
432- return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
433-}
434-
435-func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
436- if l.publicTLSSvc == nil {
437- return &dto.TLSStatusResponse{
438- ACMEEnabled: false,
439- SelectionReason: "public TLS service not configured",
440- }, nil
441- }
442-
443- status := l.publicTLSSvc.Status(ctx)
444- result := dto.TLSStatusFromDomain(status)
445- return &result, nil
446-}
447-
448-func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) {
449- return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL: %w", domain.ErrTrafficStatusUnavailable)
450-}
451-
452-func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
453- if l.configSvc == nil {
454- return nil, fmt.Errorf("local config service unavailable")
455- }
456-
457- status := &remote.Status{
458- Routes: len(l.configSvc.GetRoutes(ctx)),
459- RegistryDomain: l.configSvc.GetRegistryDomain(),
460- RegistryPort: l.configSvc.GetRegistryPort(),
461- ServerPort: l.configSvc.GetServerPort(),
462- AutoRoute: l.configSvc.IsAutoRouteEnabled(),
463- NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
464- ContainerStatus: map[string]string{},
465- }
466-
467- if l.containerSvc != nil {
468- for domainName, container := range l.containerSvc.List(ctx) {
469- if container == nil {
470- continue
471- }
472- status.ContainerStatus[domainName] = container.Status
473- }
474- }
475-
476- return status, nil
477-}
478-
479-func (l *localControlPlane) Reload(_ context.Context) error {
480- return app.SendReloadSignal()
481-}
482-
483-func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
484- if l.containerSvc == nil {
485- return nil, fmt.Errorf("container service unavailable")
486- }
487- return l.containerSvc.ListNetworks(ctx)
488-}
489-
490-func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
491- if l.configSvc == nil {
492- return nil, fmt.Errorf("config service unavailable")
493- }
494- externalRoutes := l.configSvc.GetExternalRoutes()
495- externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes))
496- for domainName := range externalRoutes {
497- externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName})
498- }
499- sort.Slice(externalResponses, func(i, j int) bool {
500- return externalResponses[i].Domain < externalResponses[j].Domain
501- })
502- cfg := &remote.Config{
503- Routes: l.configSvc.GetRoutes(ctx),
504- ExternalRoutes: externalResponses,
505- }
506- cfg.Server.Port = l.configSvc.GetServerPort()
507- cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
508- cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
509- cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
510- cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
511- cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
512- if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
513- cfg.Volumes.AutoCreate, cfg.Volumes.Prefix, cfg.Volumes.Preserve = volumeCfg.GetVolumeConfig()
514- }
515- return cfg, nil
516-}
517-
518-func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
519- if l.deployCoord != nil {
520- l.deployCoord.SuppressDeployEvent(imageName)
521- }
522- return nil
523-}
524-
525-func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
526- if l.containerSvc != nil && l.configSvc != nil {
527- route, err := l.configSvc.GetRoute(ctx, deployDomain)
528- if err != nil {
529- return nil, err
530- }
531- container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
532- if err != nil {
533- return nil, err
534- }
535- result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
536- if container != nil {
537- result.ContainerID = container.ID
538- }
539- return result, nil
540- }
541-
542- domainName, err := app.SendDeploySignal(deployDomain)
543- if err != nil {
544- return nil, err
545- }
546- return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
547-}
548-
549-func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
550- if l.containerSvc == nil {
551- if withAttachments {
552- return nil, fmt.Errorf("local restart with attachments requires active local container service")
553- }
554- domainName, err := app.SendDeploySignal(restartDomain)
555- if err != nil {
556- return nil, err
557- }
558- return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
559- }
560-
561- if err := l.containerSvc.SyncContainers(ctx); err != nil {
562- return nil, err
563- }
564- if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
565- return nil, err
566- }
567-
568- return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
569-}
570-
571-func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
572- if l.registrySvc == nil {
573- return nil, fmt.Errorf("local registry service unavailable")
574- }
575- return l.registrySvc.ListTags(ctx, repository)
576-}
577-
578-func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
579- if l.backupSvc == nil {
580- return nil, fmt.Errorf("local backup service unavailable")
581- }
582- jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
583- if err != nil {
584- return nil, err
585- }
586- return toDTOBackupJobs(jobs), nil
587-}
588-
589-func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
590- if l.backupSvc == nil {
591- return nil, fmt.Errorf("local backup service unavailable")
592- }
593- jobs, err := l.backupSvc.Status(ctx)
594- if err != nil {
595- return nil, err
596- }
597- return toDTOBackupJobs(jobs), nil
598-}
599-
600-func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
601- if l.backupSvc == nil {
602- return nil, fmt.Errorf("local backup service unavailable")
603- }
604- result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
605- if err != nil {
606- return nil, err
607- }
608- if result == nil {
609- return &dto.BackupRunResponse{Status: "ok"}, nil
610- }
611- job := toDTOBackupJob(result.Job)
612- return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
613-}
614-
615-func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
616- if l.backupSvc == nil {
617- return nil, fmt.Errorf("local backup service unavailable")
618- }
619- dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
620- if err != nil {
621- return nil, err
622- }
623- out := make([]dto.DatabaseInfo, 0, len(dbs))
624- for _, db := range dbs {
625- out = append(out, dto.DatabaseInfo{
626- Type: string(db.Type),
627- Name: db.Name,
628- Version: db.Version,
629- Host: db.Host,
630- Port: db.Port,
631- ContainerID: db.ContainerID,
632- ImageName: db.ImageName,
633- })
634- }
635- return out, nil
636-}
637-
638-func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
639- if l.volumeBackupSvc == nil {
640- return nil, localVolumeBackupServiceUnavailable()
641- }
642- jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
643- if err != nil {
644- return nil, fmt.Errorf("list volume backups: %w", err)
645- }
646- return toDTOVolumeBackupJobs(jobs), nil
647-}
648-
649-func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
650- if l.volumeBackupSvc == nil {
651- return nil, localVolumeBackupServiceUnavailable()
652- }
653- jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
654- if err != nil {
655- return nil, fmt.Errorf("get volume backup status: %w", err)
656- }
657- return toDTOVolumeBackupJobs(jobs), nil
658-}
659-
660-func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
661- if l.volumeBackupSvc == nil {
662- return nil, localVolumeBackupServiceUnavailable()
663- }
664- jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
665- if err != nil {
666- if len(jobs) > 0 {
667- resp := &dto.VolumeBackupRunResponse{Status: "partial", Backups: toDTOVolumeBackupJobs(jobs), Error: err.Error()}
668- return resp, fmt.Errorf("run volume backups: %w", err)
669- }
670- return nil, fmt.Errorf("run volume backups: %w", err)
671- }
672- return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
673-}
674-
675-func localVolumeBackupServiceUnavailable() error {
676- return fmt.Errorf("local volume backup service unavailable: %w", domain.ErrVolumeBackupUnavailable)
677-}
678-
679-func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
680- if l.logSvc == nil {
681- return nil, fmt.Errorf("local log service unavailable")
682- }
683- return l.logSvc.GetProcessLogs(ctx, lines)
684-}
685-
686-func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
687- if l.logSvc == nil {
688- return nil, fmt.Errorf("local log service unavailable")
689- }
690- return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
691-}
692-
693-func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
694- if l.logSvc == nil {
695- return nil, fmt.Errorf("local log service unavailable")
696- }
697- return l.logSvc.FollowProcessLogs(ctx, lines)
698-}
699-
700-func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
701- if l.logSvc == nil {
702- return nil, fmt.Errorf("local log service unavailable")
703- }
704- return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
705-}
706-
707-func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
708- if l.volumeSvc == nil {
709- return nil, fmt.Errorf("volume service unavailable")
710- }
711- vols, err := l.volumeSvc.ListVolumes(ctx)
712- if err != nil {
713- return nil, err
714- }
715-
716- result := make([]dto.Volume, len(vols))
717- for i, v := range vols {
718- result[i] = dto.Volume{
719- Name: v.Name,
720- Driver: v.Driver,
721- MountPoint: v.MountPoint,
722- Size: v.Size,
723- CreatedAt: v.CreatedAt,
724- InUse: v.InUse,
725- Containers: v.Containers,
726- Labels: v.Labels,
727- }
728- }
729- return result, nil
730-}
731-
732-func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
733- if l.volumeSvc == nil {
734- return nil, fmt.Errorf("volume service unavailable")
735- }
736- report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
737- if err != nil {
738- return nil, err
739- }
740-
741- vols := make([]dto.Volume, len(removed))
742- for i, v := range removed {
743- vols[i] = dto.Volume{
744- Name: v.Name,
745- Size: v.Size,
746- }
747- }
748-
749- return &dto.VolumePruneResponse{
750- VolumesRemoved: report.VolumesRemoved,
751- SpaceReclaimed: report.SpaceReclaimed,
752- Volumes: vols,
753- }, nil
754-}
755-
756-func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo {
757- detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
758- for _, info := range detailed {
759- detailedByDomain[info.Domain] = info
760- }
761-
762- merged := make([]domain.RouteInfo, 0, len(configured))
763- for _, route := range configured {
764- info, ok := detailedByDomain[route.Domain]
765- if !ok {
766- info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
767- } else if info.Image == "" {
768- info.Image = route.Image
769- }
770- merged = append(merged, info)
771- }
772-
773- return toRemoteRouteInfos(merged)
774-}
775-
776-func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
777- out := make([]remote.RouteInfo, 0, len(routes))
778- for _, route := range routes {
779- attachments := make([]dto.Attachment, 0, len(route.Attachments))
780- for _, attachment := range route.Attachments {
781- attachments = append(attachments, dto.Attachment{
782- Name: attachment.Name,
783- Image: attachment.Image,
784- ContainerID: attachment.ContainerID,
785- Status: attachment.Status,
786- Network: attachment.Network,
787- })
788- }
789-
790- out = append(out, remote.RouteInfo{
791- Domain: route.Domain,
792- Image: route.Image,
793- ContainerID: route.ContainerID,
794- ContainerStatus: route.ContainerStatus,
795- Network: route.Network,
796- Attachments: attachments,
797- })
798- }
799- return out
800-}
801-
802-func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
803- out := make([]dto.BackupJob, 0, len(jobs))
804- for _, job := range jobs {
805- out = append(out, toDTOBackupJob(job))
806- }
807- return out
808-}
809-
810-func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
811- var startedAt *time.Time
812- if !job.StartedAt.IsZero() {
813- t := job.StartedAt
814- startedAt = &t
815- }
816- var completedAt *time.Time
817- if !job.CompletedAt.IsZero() {
818- t := job.CompletedAt
819- completedAt = &t
820- }
821-
822- return dto.BackupJob{
823- ID: job.ID,
824- Domain: job.Domain,
825- DBName: job.DBName,
826- Schedule: string(job.Schedule),
827- Type: string(job.Type),
828- Status: string(job.Status),
829- StartedAt: startedAt,
830- CompletedAt: completedAt,
831- SizeBytes: job.SizeBytes,
832- Error: job.Error,
833- }
834-}
835-
836-func toDTOVolumeBackupJobs(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
837- out := make([]dto.VolumeBackupJob, 0, len(jobs))
838- for _, job := range jobs {
839- out = append(out, toDTOVolumeBackupJob(job))
840- }
841- return out
842-}
843-
844-func toDTOVolumeBackupJob(job domain.VolumeBackupJob) dto.VolumeBackupJob {
845- var startedAt *time.Time
846- if !job.StartedAt.IsZero() {
847- t := job.StartedAt
848- startedAt = &t
849- }
850- var completedAt *time.Time
851- if !job.CompletedAt.IsZero() {
852- t := job.CompletedAt
853- completedAt = &t
854- }
855-
856- return dto.VolumeBackupJob{
857- ID: job.ID,
858- Domain: job.Domain,
859- ContainerName: job.ContainerName,
860- ContainerID: job.ContainerID,
861- VolumeName: job.VolumeName,
862- MountPath: job.MountPath,
863- Compression: job.Metadata["compression"],
864- Type: string(job.Type),
865- Status: string(job.Status),
866- StartedAt: startedAt,
867- CompletedAt: completedAt,
868- SizeBytes: job.SizeBytes,
869- ArtifactRef: job.ArtifactRef,
870- Error: job.Error,
871- }
872-}
D internal/adapters/in/cli/controlplane_local_test.go +0 -308
@@ -1,308 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "errors"
6- "testing"
7- "time"
8-
9- "github.com/bnema/gordon/internal/adapters/dto"
10- inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
11- "github.com/bnema/gordon/internal/domain"
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
15-)
16-
17-type previewContainerService struct {
18- *inmocks.MockContainerService
19- preview func(context.Context, string) (*domain.CleanupReport, error)
20-}
21-
22-func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
23- if s.preview == nil {
24- return nil, nil
25- }
26- return s.preview(ctx, routeDomain)
27-}
28-
29-func TestLocalControlPlane_GetStatus(t *testing.T) {
30- t.Parallel()
31-
32- configSvc := inmocks.NewMockConfigService(t)
33- containerSvc := inmocks.NewMockContainerService(t)
34-
35- ctx := context.Background()
36- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}})
37- configSvc.EXPECT().GetRegistryDomain().Return("registry.local")
38- configSvc.EXPECT().GetRegistryPort().Return(5000)
39- configSvc.EXPECT().GetServerPort().Return(80)
40- configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
41- configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true)
42- containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
43- "app.local": {ID: "abc123", Status: "running"},
44- })
45-
46- cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
47- status, err := cp.GetStatus(ctx)
48- require.NoError(t, err)
49- require.Equal(t, 1, status.Routes)
50- require.Equal(t, "registry.local", status.RegistryDomain)
51- require.Equal(t, "running", status.ContainerStatus["app.local"])
52-}
53-
54-func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) {
55- t.Parallel()
56-
57- configSvc := inmocks.NewMockConfigService(t)
58- containerSvc := inmocks.NewMockContainerService(t)
59-
60- ctx := context.Background()
61- route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"}
62-
63- require.False(t, domain.IsInternalDeploy(ctx))
64-
65- configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil)
66- containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) {
67- require.True(t, domain.IsInternalDeploy(deployCtx))
68- require.Equal(t, *route, deployedRoute)
69- return &domain.Container{ID: "container-1"}, nil
70- })
71-
72- cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
73- result, err := cp.Deploy(ctx, "app.local")
74- require.NoError(t, err)
75- require.NotNil(t, result)
76- require.Equal(t, "deployed", result.Status)
77- require.Equal(t, "app.local", result.Domain)
78- require.Equal(t, "container-1", result.ContainerID)
79-}
80-
81-func TestLocalControlPlane_Backups(t *testing.T) {
82- t.Parallel()
83-
84- backupSvc := inmocks.NewMockBackupService(t)
85- ctx := context.Background()
86-
87- now := time.Now().UTC()
88- jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}}
89- backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil)
90- backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil)
91- backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil)
92- backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil)
93-
94- cp := &localControlPlane{backupSvc: backupSvc}
95-
96- list, err := cp.ListBackups(ctx, "app.local")
97- require.NoError(t, err)
98- require.Len(t, list, 1)
99-
100- status, err := cp.BackupStatus(ctx)
101- require.NoError(t, err)
102- require.Len(t, status, 1)
103-
104- run, err := cp.RunBackup(ctx, "app.local", "postgres")
105- require.NoError(t, err)
106- require.NotNil(t, run.Backup)
107- require.Equal(t, "b1", run.Backup.ID)
108-
109- dbs, err := cp.DetectDatabases(ctx, "app.local")
110- require.NoError(t, err)
111- require.Len(t, dbs, 1)
112- require.Equal(t, "postgres", dbs[0].Name)
113-}
114-
115-func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116- t.Parallel()
117-
118- volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119- ctx := context.Background()
120- runErr := errors.New("one volume failed")
121- jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122- volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123-
124- cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125- result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126-
127- require.ErrorIs(t, err, runErr)
128- require.NotNil(t, result)
129- assert.Equal(t, "partial", result.Status)
130- assert.Equal(t, runErr.Error(), result.Error)
131- require.Len(t, result.Backups, 1)
132- assert.Equal(t, "v1", result.Backups[0].ID)
133-}
134-
135-func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136- t.Parallel()
137-
138- cp := &localControlPlane{}
139- result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140-
141- require.Error(t, err)
142- assert.Nil(t, result)
143- assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144-}
145-
146-func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147- t.Parallel()
148-
149- volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150- wantErr := errors.New("store unavailable")
151- volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152-
153- cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154- jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155-
156- require.ErrorIs(t, err, wantErr)
157- assert.Contains(t, err.Error(), "list volume backups")
158- assert.Nil(t, jobs)
159-}
160-
161-func TestLocalControlPlane_ListTags(t *testing.T) {
162- t.Parallel()
163-
164- registrySvc := inmocks.NewMockRegistryService(t)
165- registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil)
166-
167- cp := &localControlPlane{registrySvc: registrySvc}
168- tags, err := cp.ListTags(context.Background(), "repo/app")
169- require.NoError(t, err)
170- require.Equal(t, []string{"v1.0.0", "latest"}, tags)
171-}
172-
173-func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) {
174- t.Parallel()
175-
176- configSvc := inmocks.NewMockConfigService(t)
177- containerSvc := inmocks.NewMockContainerService(t)
178- report := &domain.CleanupReport{
179- Domain: "app.local",
180- RemovedContainers: []domain.CleanupContainer{{
181- ID: "container-1",
182- Name: "gordon-app.local",
183- }},
184- }
185-
186- removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once()
187- cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
188- mock.InOrder(removeCall, cleanupCall)
189-
190- cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
191- err := cp.RemoveRoute(context.Background(), "app.local")
192- require.NoError(t, err)
193-}
194-
195-func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
196- t.Parallel()
197-
198- containerSvc := inmocks.NewMockContainerService(t)
199- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
200- containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil)
201-
202- cp := &localControlPlane{containerSvc: containerSvc}
203- result, err := cp.Restart(context.Background(), "app.local", true)
204- require.NoError(t, err)
205- require.Equal(t, "app.local", result.Domain)
206-}
207-
208-func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) {
209- t.Parallel()
210-
211- containerSvc := inmocks.NewMockContainerService(t)
212- syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
213- listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{
214- Domain: "app.local",
215- Image: "repo/app:latest",
216- ContainerID: "container-1",
217- ContainerStatus: "running",
218- }}).Once()
219- mock.InOrder(syncCall, listCall)
220-
221- cp := &localControlPlane{containerSvc: containerSvc}
222- routes, err := cp.ListRoutesWithDetails(context.Background())
223- require.NoError(t, err)
224- require.Len(t, routes, 1)
225- require.Equal(t, "app.local", routes[0].Domain)
226- require.Equal(t, "repo/app:latest", routes[0].Image)
227- require.Equal(t, "container-1", routes[0].ContainerID)
228- require.Equal(t, "running", routes[0].ContainerStatus)
229-}
230-
231-func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) {
232- t.Parallel()
233-
234- configSvc := inmocks.NewMockConfigService(t)
235- containerSvc := inmocks.NewMockContainerService(t)
236-
237- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
238- Domain: "app.local",
239- Image: "repo/app:latest",
240- }}).Once()
241- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
242- containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
243-
244- cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
245- routes, err := cp.ListRoutesWithDetails(context.Background())
246- require.NoError(t, err)
247- require.Len(t, routes, 1)
248- require.Equal(t, "app.local", routes[0].Domain)
249- require.Equal(t, "repo/app:latest", routes[0].Image)
250- require.Empty(t, routes[0].ContainerID)
251- require.Empty(t, routes[0].ContainerStatus)
252-}
253-
254-func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
255- t.Parallel()
256-
257- cp := &localControlPlane{}
258- status, err := cp.GetTLSStatus(context.Background())
259- require.NoError(t, err)
260- require.Equal(t, &dto.TLSStatusResponse{
261- ACMEEnabled: false,
262- SelectionReason: "public TLS service not configured",
263- }, status)
264-}
265-
266-func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
267- t.Parallel()
268-
269- logSvc := inmocks.NewMockLogService(t)
270- logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil)
271-
272- cp := &localControlPlane{logSvc: logSvc}
273- lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50)
274- require.NoError(t, err)
275- require.Equal(t, []string{"line1", "line2"}, lines)
276-}
277-
278-func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) {
279- ctx := context.Background()
280- volumeSvc := inmocks.NewMockVolumeService(t)
281- containerSvc := &previewContainerService{
282- MockContainerService: inmocks.NewMockContainerService(t),
283- preview: func(context.Context, string) (*domain.CleanupReport, error) {
284- return nil, nil
285- },
286- }
287- cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc}
288-
289- report, err := cp.GetRouteCleanupPreview(ctx, "app.local")
290- require.NoError(t, err)
291- assert.Nil(t, report)
292-}
293-
294-func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
295- ctx := context.Background()
296- configSvc := inmocks.NewMockConfigService(t)
297- containerSvc := inmocks.NewMockContainerService(t)
298- report := &domain.CleanupReport{Domain: "app.local"}
299-
300- configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once()
301- containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
302-
303- cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
304- resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")
305- require.NoError(t, err)
306- require.NotNil(t, resp)
307- assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup)
308-}
M internal/adapters/in/cli/controlplane_remote.go +40 -102
@@ -9,56 +9,66 @@ import (
99 "github.com/bnema/gordon/internal/domain"
1010 )
1111
12+// remoteControlPlane delegates every CLI operation to one daemon client:
13+// the explicit remote or the owner-only local admin socket.
1214 type remoteControlPlane struct {
1315 client *remote.Client
1416 }
1517
18+// NewRemoteControlPlane creates the daemon-backed control plane.
1619 func NewRemoteControlPlane(client *remote.Client) ControlPlane {
1720 return &remoteControlPlane{client: client}
1821 }
1922
20-func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
21- return r.client.ListRoutesWithDetails(ctx)
23+func (r *remoteControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
24+ return r.client.ApplyApp(ctx, req)
2225 }
2326
24-func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
25- return r.client.GetHealth(ctx)
27+func (r *remoteControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
28+ return r.client.ListApps(ctx)
2629 }
2730
28-func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
29- return r.client.GetRoute(ctx, routeDomain)
31+func (r *remoteControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
32+ return r.client.ShowApp(ctx, app)
3033 }
3134
32-func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
33- return r.client.FindRoutesByImage(ctx, imageName)
35+func (r *remoteControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
36+ return r.client.DiffApp(ctx, app)
3437 }
3538
36-func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
37- return r.client.AddRoute(ctx, route)
39+func (r *remoteControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
40+ return r.client.DeployApp(ctx, app, req)
3841 }
3942
40-func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
41- return r.client.UpdateRoute(ctx, route)
43+func (r *remoteControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
44+ return r.client.StopApp(ctx, app)
4245 }
4346
44-func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
45- _, err := r.RemoveRouteWithCleanup(ctx, routeDomain)
46- return err
47+func (r *remoteControlPlane) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
48+ return r.client.StartApp(ctx, app)
4749 }
4850
49-func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
50- return r.client.RemoveRouteWithCleanup(ctx, routeDomain)
51+func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {
52+ return r.client.RestartApp(ctx, app, service)
5153 }
5254
53-func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
54- return r.client.GetRouteCleanupPreview(ctx, routeDomain)
55+func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
56+ return r.client.RemoveApp(ctx, app)
5557 }
5658
57-func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
58- return r.client.Bootstrap(ctx, req)
59+func (r *remoteControlPlane) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) {
60+ return r.client.OperationByKey(ctx, app, key)
5961 }
6062
61-func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
63+func (r *remoteControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
64+ return r.client.SetAppSecrets(ctx, app, req)
65+}
66+
67+func (r *remoteControlPlane) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error {
68+ return r.client.DeleteAppSecret(ctx, app, req)
69+}
70+
71+func (r *remoteControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
6272 return r.client.ListSecretsWithAttachments(ctx, secretDomain)
6373 }
6474
@@ -70,54 +80,6 @@ func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key
7080 return r.client.DeleteSecret(ctx, secretDomain, key)
7181 }
7282
73-func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
74- return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets)
75-}
76-
77-func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
78- return r.client.DeleteAttachmentSecret(ctx, domainName, service, key)
79-}
80-
81-func (r *remoteControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
82- return r.client.ListOrphanedAttachments(ctx)
83-}
84-
85-func (r *remoteControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
86- return r.client.CleanupOrphanedAttachments(ctx, owner, stop)
87-}
88-
89-func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
90- return r.client.GetAllAttachmentsConfig(ctx)
91-}
92-
93-func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
94- return r.client.GetAttachmentsConfig(ctx, domainOrGroup)
95-}
96-
97-func (r *remoteControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
98- return r.client.FindAttachmentTargetsByImage(ctx, imageName)
99-}
100-
101-func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
102- return r.client.AddAttachment(ctx, domainOrGroup, image)
103-}
104-
105-func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
106- return r.client.RemoveAttachment(ctx, domainOrGroup, image)
107-}
108-
109-func (r *remoteControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
110- return r.client.GetAutoRouteAllowedDomains(ctx)
111-}
112-
113-func (r *remoteControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
114- return r.client.AddAutoRouteAllowedDomain(ctx, pattern)
115-}
116-
117-func (r *remoteControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
118- return r.client.RemoveAutoRouteAllowedDomain(ctx, pattern)
119-}
120-
12183 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
12284 return r.client.GetStatus(ctx)
12385 }
@@ -142,40 +104,24 @@ func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, err
142104 return r.client.GetConfig(ctx)
143105 }
144106
145-func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {
146- return r.client.DeployIntent(ctx, imageName)
147-}
148-
149-func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
150- return r.client.Deploy(ctx, deployDomain)
151-}
152-
153-func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
154- return r.client.Restart(ctx, restartDomain, withAttachments)
155-}
156-
157107 func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
158108 return r.client.ListTags(ctx, repository)
159109 }
160110
161-func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
162- return r.client.ListBackups(ctx, backupDomain)
111+func (r *remoteControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
112+ return r.client.ListBackups(ctx, app)
163113 }
164114
165115 func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
166116 return r.client.BackupStatus(ctx)
167117 }
168118
169-func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
170- return r.client.RunBackup(ctx, backupDomain, dbName)
171-}
172-
173-func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
174- return r.client.DetectDatabases(ctx, backupDomain)
119+func (r *remoteControlPlane) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) {
120+ return r.client.RunBackup(ctx, app, service, database)
175121 }
176122
177-func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
178- jobs, err := r.client.ListVolumeBackups(ctx, backupDomain)
123+func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
124+ jobs, err := r.client.ListVolumeBackups(ctx, app)
179125 if err != nil {
180126 return nil, fmt.Errorf("list volume backups: %w", err)
181127 }
@@ -190,8 +136,8 @@ func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.Volu
190136 return jobs, nil
191137 }
192138
193-func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
194- result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
139+func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) {
140+ result, err := r.client.RunVolumeBackups(ctx, app, service, volume)
195141 if err != nil {
196142 return result, fmt.Errorf("run volume backups: %w", err)
197143 }
@@ -202,18 +148,10 @@ func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]s
202148 return r.client.GetProcessLogs(ctx, lines)
203149 }
204150
205-func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
206- return r.client.GetContainerLogs(ctx, logDomain, lines)
207-}
208-
209151 func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
210152 return r.client.StreamProcessLogs(ctx, lines)
211153 }
212154
213-func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
214- return r.client.StreamContainerLogs(ctx, logDomain, lines)
215-}
216-
217155 func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
218156 return r.client.ListVolumes(ctx)
219157 }
M internal/adapters/in/cli/controlplane_remote_test.go +2 -2
@@ -30,14 +30,14 @@ func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T)
3030 w.WriteHeader(http.StatusPartialContent)
3131 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
3232 Status: "partial",
33- Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}},
33+ Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data"}},
3434 Error: "one volume failed",
3535 }))
3636 }))
3737 t.Cleanup(server.Close)
3838
3939 cp := NewRemoteControlPlane(remote.NewClient(server.URL))
40- result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")
40+ result, err := cp.RunVolumeBackups(context.Background(), "shop", "api", "data")
4141
4242 require.Error(t, err)
4343 assert.Contains(t, err.Error(), "run volume backups")
M internal/adapters/in/cli/controlplane_resolver.go +20 -36
@@ -5,7 +5,6 @@ import (
55 "fmt"
66
77 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8- "github.com/bnema/gordon/internal/app"
98 )
109
1110 type controlPlaneHandle struct {
@@ -24,16 +23,27 @@ func (h *controlPlaneHandle) close() {
2423 }
2524 }
2625
27-func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
26+var newLocalControlPlaneClient = remote.NewLocalClient
27+
28+func resolveDaemonClient() (*remote.Client, bool, error) {
2829 client, isRemote, err := GetRemoteClient()
29- if err != nil {
30- return nil, err
30+ if err != nil || isRemote {
31+ return client, isRemote, err
3132 }
32- if isRemote {
33- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
33+
34+ client, err = newLocalControlPlaneClient()
35+ if err != nil {
36+ return nil, false, fmt.Errorf("local control plane unavailable: %w", err)
3437 }
38+ return client, false, nil
39+}
3540
36- return resolveLocalControlPlane(configPath)
41+func resolveControlPlane(_ string) (*controlPlaneHandle, error) {
42+ client, isRemote, err := resolveDaemonClient()
43+ if err != nil {
44+ return nil, err
45+ }
46+ return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: isRemote}, nil
3747 }
3848
3949 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {
@@ -41,22 +51,8 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan
4151 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}
4252 }
4353
44-func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
45- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
46- return inferRemoteForRouteDomain(ctx, routeDomain)
47- })
48-}
49-
50-func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
51- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
52- return inferRemoteForRouteCleanupDomain(ctx, routeDomain)
53- })
54-}
55-
56-func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) {
57- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
58- return inferRemoteForAttachmentTarget(ctx, target)
59- })
54+func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) {
55+ return resolveControlPlaneWithInference(ctx, inferExplicitTarget)
6056 }
6157
6258 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
@@ -73,17 +69,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co
7369 if resolved != nil {
7470 return newRemoteControlPlaneHandle(resolved), nil
7571 }
76- return resolveControlPlane(configPath)
77-}
78-
79-func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
80- kernel, err := app.NewKernelQuiet(configPath)
81- if err != nil {
82- return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
83- }
84-
85- return &controlPlaneHandle{
86- plane: NewLocalControlPlane(kernel),
87- closeFn: kernel.Close,
88- }, nil
72+ return resolveControlPlane(cliConfigPath)
8973 }
M internal/adapters/in/cli/controlplane_resolver_test.go +94 -70
@@ -1,110 +1,134 @@
11 package cli
22
33 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
48 "os"
59 "path/filepath"
610 "testing"
11+ "time"
712
13+ "github.com/stretchr/testify/assert"
814 "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/localadmin"
918 )
1019
11-func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
12- tmpDir := t.TempDir()
13- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
20+func isolateControlPlaneResolver(t *testing.T) {
21+ t.Helper()
22+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
1423 t.Setenv("GORDON_REMOTE", "")
1524 t.Setenv("GORDON_TOKEN", "")
1625 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
26+ originalFactory := newLocalControlPlaneClient
1727 t.Cleanup(func() {
1828 remoteFlag = originalRemoteFlag
1929 tokenFlag = originalTokenFlag
2030 insecureTLSFlag = originalInsecureTLSFlag
31+ newLocalControlPlaneClient = originalFactory
2132 })
22-
2333 remoteFlag = ""
2434 tokenFlag = ""
2535 insecureTLSFlag = false
36+}
2637
27- configPath := filepath.Join(tmpDir, "gordon.toml")
28- err := os.WriteFile(configPath, []byte(`[server]
29-gordon_domain = "gordon.local"
30-data_dir = "`+filepath.Join(tmpDir, "data")+`"
31-
32-[auth]
33-enabled = true
34-secrets_backend = "unsafe"
35-`), 0o600)
38+func localControlPlaneTestClient(t *testing.T, handler http.Handler) *remote.Client {
39+ t.Helper()
40+ dir := t.TempDir()
41+ listener, _, err := localadmin.Listen(dir)
3642 require.NoError(t, err)
43+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
44+ go func() { _ = server.Serve(listener) }()
45+ t.Cleanup(func() { _ = server.Close() })
46+ return remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
47+}
3748
38- handle, err := resolveControlPlane(configPath)
49+func TestResolveControlPlane_LocalUsesSocketBackedRemotePlane(t *testing.T) {
50+ isolateControlPlaneResolver(t)
51+ requests := make(chan string, 1)
52+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ requests <- r.URL.Path
54+ w.Header().Set("Content-Type", "application/json")
55+ _ = json.NewEncoder(w).Encode(remote.Status{RegistryDomain: "socket.local"})
56+ }))
57+ factoryCalls := 0
58+ newLocalControlPlaneClient = func() (*remote.Client, error) {
59+ factoryCalls++
60+ return client, nil
61+ }
62+
63+ // A deliberately unusable config path proves local resolution does not open a kernel/store.
64+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "missing", "gordon.toml"))
3965 require.NoError(t, err)
4066 require.NotNil(t, handle)
41- require.NotNil(t, handle.plane)
42- defer handle.close()
43-}
67+ assert.False(t, handle.isRemote)
68+ assert.IsType(t, &remoteControlPlane{}, handle.plane)
69+ assert.Equal(t, 1, factoryCalls)
4470
45-func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
46- tmpDir := t.TempDir()
47- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
48- t.Setenv("GORDON_REMOTE", "")
49- t.Setenv("GORDON_TOKEN", "")
50- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
51- t.Cleanup(func() {
52- remoteFlag = originalRemoteFlag
53- tokenFlag = originalTokenFlag
54- insecureTLSFlag = originalInsecureTLSFlag
55- })
71+ status, err := handle.plane.GetStatus(context.Background())
72+ require.NoError(t, err)
73+ assert.Equal(t, "socket.local", status.RegistryDomain)
74+ select {
75+ case path := <-requests:
76+ assert.Equal(t, "/admin/status", path)
77+ case <-time.After(2 * time.Second):
78+ t.Fatal("socket server received no request")
79+ }
80+}
5681
57- remoteFlag = ""
58- tokenFlag = ""
59- insecureTLSFlag = false
82+func TestResolveControlPlane_LocalFailsClosedWhenDaemonUnavailable(t *testing.T) {
83+ isolateControlPlaneResolver(t)
84+ newLocalControlPlaneClient = func() (*remote.Client, error) {
85+ return nil, remote.ErrDaemonUnavailable
86+ }
6087
61- configPath := filepath.Join(tmpDir, "gordon.toml")
62- err := os.WriteFile(configPath, []byte(`[server]
63-gordon_domain = "gordon.local"
64-data_dir = "`+filepath.Join(tmpDir, "data")+`"
88+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "would-create-store.toml"))
89+ require.Error(t, err)
90+ assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
91+ assert.Nil(t, handle)
92+}
6593
66-[auth]
67-enabled = false
68-secrets_backend = "unsafe"
69-`), 0o600)
70- require.NoError(t, err)
94+func TestResolveControlPlane_ExplicitRemoteDoesNotUseLocalFactory(t *testing.T) {
95+ isolateControlPlaneResolver(t)
96+ remoteFlag = "https://example.invalid"
97+ tokenFlag = "token"
98+ newLocalControlPlaneClient = func() (*remote.Client, error) {
99+ t.Fatal("local factory invoked for explicit remote")
100+ return nil, errors.New("unreachable")
101+ }
71102
72- handle, err := resolveControlPlane(configPath)
103+ handle, err := resolveControlPlane("")
73104 require.NoError(t, err)
74- require.NotNil(t, handle)
75- require.NotNil(t, handle.plane)
76- defer handle.close()
105+ assert.True(t, handle.isRemote)
106+ assert.IsType(t, &remoteControlPlane{}, handle.plane)
77107 }
78108
79109 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80- tmpDir := t.TempDir()
81- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
82- t.Setenv("GORDON_REMOTE", "")
83- t.Setenv("GORDON_TOKEN", "")
84- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
85- t.Cleanup(func() {
86- remoteFlag = originalRemoteFlag
87- tokenFlag = originalTokenFlag
88- insecureTLSFlag = originalInsecureTLSFlag
89- })
90-
110+ isolateControlPlaneResolver(t)
111+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
91112 remoteFlag = "does-not-exist"
92- tokenFlag = ""
93- insecureTLSFlag = false
94113
95- configPath := filepath.Join(tmpDir, "gordon.toml")
96- err := os.WriteFile(configPath, []byte(`[server]
97-gordon_domain = "gordon.local"
98-data_dir = "`+filepath.Join(tmpDir, "data")+`"
114+ handle, err := resolveControlPlane("")
115+ require.Error(t, err)
116+ assert.Nil(t, handle)
117+ assert.Contains(t, err.Error(), "does-not-exist")
118+}
99119
100-[auth]
101-enabled = false
102-secrets_backend = "unsafe"
103-`), 0o600)
120+func TestResolveControlPlane_LocalIgnoresConfigAndStoreState(t *testing.T) {
121+ isolateControlPlaneResolver(t)
122+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
123+ w.Header().Set("Content-Type", "application/json")
124+ _ = json.NewEncoder(w).Encode(remote.Status{})
125+ }))
126+ newLocalControlPlaneClient = func() (*remote.Client, error) { return client, nil }
127+
128+ lockedStore := filepath.Join(t.TempDir(), "state.db")
129+ require.NoError(t, os.WriteFile(lockedStore, []byte("active daemon store"), 0o600))
130+ handle, err := resolveControlPlane(lockedStore)
131+ require.NoError(t, err)
132+ _, err = handle.plane.GetStatus(context.Background())
104133 require.NoError(t, err)
105-
106- handle, err := resolveControlPlane(configPath)
107- require.Error(t, err)
108- require.Nil(t, handle)
109- require.Contains(t, err.Error(), "does-not-exist")
110134 }
D internal/adapters/in/cli/deploy.go +0 -114
@@ -1,114 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7-
8- "github.com/spf13/cobra"
9-
10- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11- "github.com/bnema/gordon/internal/app"
12-)
13-
14-type deployer interface {
15- Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
16-}
17-
18-var sendDeploySignal = app.SendDeploySignal
19-
20-// newDeployCmd creates the deploy command.
21-func newDeployCmd() *cobra.Command {
22- var jsonOut bool
23- cmd := &cobra.Command{
24- Use: "deploy <domain>",
25- Short: "Manually deploy or redeploy a route",
26- Long: `Triggers a deployment for the specified route domain.
27-The route must be configured in config.toml.
28-
29-This will pull the latest image and deploy/redeploy the container,
30-even if a container is already running.
31-
32-Examples:
33- gordon deploy myapp.example.com
34- gordon deploy api.example.com
35- gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
36- Args: cobra.ExactArgs(1),
37- RunE: func(cmd *cobra.Command, args []string) error {
38- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
39- if err != nil {
40- return err
41- }
42- defer handle.close()
43-
44- return runDeploy(cmd.Context(), handle.plane, handle.isRemote, args[0], cmd.OutOrStdout(), jsonOut)
45- },
46- }
47- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
48- return cmd
49-}
50-
51-func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error {
52- result, err := deployer.Deploy(ctx, deployDomain)
53- if err != nil {
54- if formatted, ok := structuredDeployFailure(err); ok {
55- return formatted
56- }
57-
58- if isRemote && shouldFallbackToLocal(err) {
59- return handleLocalDeployFallback(err, deployDomain, out, jsonOut)
60- }
61-
62- return formatDeployFailure(err)
63- }
64-
65- if jsonOut {
66- return writeJSON(out, result)
67- }
68-
69- messageDomain := deployDomain
70- if result.Domain != "" {
71- messageDomain = result.Domain
72- }
73-
74- containerID := shortContainerID(result.ContainerID)
75-
76- switch result.Status {
77- case "deployed":
78- msg := fmt.Sprintf("Deployed %s", messageDomain)
79- if containerID != "" {
80- msg += fmt.Sprintf(" (container: %s)", containerID)
81- }
82- return cliWriteLine(out, cliRenderSuccess(msg))
83- case "queued":
84- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy queued for domain: %s", messageDomain)))
85- default:
86- msg := fmt.Sprintf("Deploy status for %s: %s", messageDomain, result.Status)
87- if containerID != "" {
88- msg += fmt.Sprintf(" (container: %s)", containerID)
89- }
90- return cliWriteLine(out, cliRenderInfo(msg))
91- }
92-}
93-
94-func handleLocalDeployFallback(err error, deployDomain string, out io.Writer, jsonOut bool) error {
95- domain, localErr := sendDeploySignal(deployDomain)
96- if localErr != nil {
97- return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %w", err, localErr)
98- }
99-
100- warning := sanitizeDeployLogLine(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))
101- if jsonOut {
102- return writeJSON(out, map[string]string{
103- "warning": warning,
104- "domain": domain,
105- "status": "success",
106- })
107- }
108-
109- if writeErr := cliWriteLine(out, cliRenderWarning(warning)); writeErr != nil {
110- return writeErr
111- }
112-
113- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
114-}
D internal/adapters/in/cli/deploy_test.go +0 -119
@@ -1,119 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "testing"
8-
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13- "github.com/bnema/gordon/internal/domain"
14-)
15-
16-type deployTestDeployer struct {
17- result *remote.DeployResult
18- err error
19-}
20-
21-func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResult, error) {
22- return d.result, d.err
23-}
24-
25-func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {
26- root := &domain.DeployFailureError{
27- Summary: "failed to deploy",
28- Cause: "health check failed",
29- Hint: "check DATABASE_URL",
30- Logs: []string{"booting app", "connection refused"},
31- }
32- err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false)
33-
34- require.Error(t, err)
35- assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
36- assert.ErrorIs(t, err, root)
37-}
38-
39-func TestRunDeploy_StructuredRemoteNoFallbackReturnsFormattedError(t *testing.T) {
40- originalSendDeploySignal := sendDeploySignal
41- sendDeploySignal = func(string) (string, error) {
42- t.Fatalf("sendDeploySignal should not be called for structured deploy failures")
43- return "", nil
44- }
45- defer func() { sendDeploySignal = originalSendDeploySignal }()
46-
47- root := &remote.HTTPError{
48- StatusCode: 503,
49- Status: "503 Service Unavailable",
50- Body: "failed to deploy",
51- Cause: "health check failed",
52- Hint: "check DATABASE_URL",
53- Logs: []string{"booting app", "connection refused"},
54- Structured: true,
55- }
56- err := runDeploy(context.Background(), &deployTestDeployer{err: root}, true, "app.example.com", &bytes.Buffer{}, false)
57-
58- require.Error(t, err)
59- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())
60- assert.ErrorIs(t, err, root)
61-}
62-
63-func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
64- var out bytes.Buffer
65- err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{
66- Status: "deployed",
67- Domain: "actual.example.com",
68- ContainerID: "1234567890abcdef",
69- }}, false, "requested.example.com", &out, false)
70-
71- require.NoError(t, err)
72- assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")
73- assert.NotContains(t, out.String(), "requested.example.com")
74-}
75-
76-func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) {
77- originalSendDeploySignal := sendDeploySignal
78- sendDeploySignal = func(string) (string, error) {
79- return "app.example.com", nil
80- }
81- defer func() { sendDeploySignal = originalSendDeploySignal }()
82-
83- var out bytes.Buffer
84- err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true)
85-
86- require.NoError(t, err)
87- assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String())
88-}
89-
90-func TestHandleLocalDeployFallback_WrapsRemoteAndLocalErrors(t *testing.T) {
91- originalSendDeploySignal := sendDeploySignal
92- localErr := errors.New("local signal failed")
93- sendDeploySignal = func(string) (string, error) {
94- return "", localErr
95- }
96- defer func() { sendDeploySignal = originalSendDeploySignal }()
97-
98- remoteErr := errors.New("remote deploy failed")
99- err := handleLocalDeployFallback(remoteErr, "app.example.com", &bytes.Buffer{}, false)
100-
101- require.Error(t, err)
102- assert.Equal(t, "failed to deploy: remote error: remote deploy failed; local fallback also failed: local signal failed", err.Error())
103- assert.ErrorIs(t, err, remoteErr)
104- assert.ErrorIs(t, err, localErr)
105-}
106-
107-func TestRunDeploy_RemoteFallbackSanitizesWarning(t *testing.T) {
108- originalSendDeploySignal := sendDeploySignal
109- sendDeploySignal = func(string) (string, error) {
110- return "app.example.com", nil
111- }
112- defer func() { sendDeploySignal = originalSendDeploySignal }()
113-
114- var out bytes.Buffer
115- err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable:\n\x1b[31mRegistry Unavailable\x1b[0m")}, true, "app.example.com", &out, true)
116-
117- require.NoError(t, err)
118- assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable:Registry Unavailable), used local signal fallback"}`, out.String())
119-}
M internal/adapters/in/cli/images.go +37 -39
@@ -70,9 +70,7 @@ func newImagesCmd() *cobra.Command {
7070 cmd := &cobra.Command{
7171 Use: "images",
7272 Short: "List and prune images",
73- Long: `Inspect and clean up runtime and registry images.
74-
75-These commands currently require remote mode with a configured target.`,
73+ Long: `Inspect and clean up runtime and registry images through the selected daemon.`,
7674 }
7775
7876 cmd.AddCommand(newImagesListCmd())
@@ -89,14 +87,10 @@ func newImagesListCmd() *cobra.Command {
8987 Use: "list",
9088 Short: "List runtime images and registry tags",
9189 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote, err := GetRemoteClient()
90+ client, _, err := resolveDaemonClient()
9391 if err != nil {
9492 return err
9593 }
96- if !isRemote {
97- return fmt.Errorf("images commands require a configured remote target")
98- }
99-
10094 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
10195 },
10296 }
@@ -115,21 +109,21 @@ func newImagesPruneCmd() *cobra.Command {
115109 Long: `Remove dangling runtime images and/or old registry tags.
116110
117111 By default both runtime and registry cleanup run, keeping latest + 3 previous
118-release tags per repository. Use --dangling or --registry to restrict scope.`,
112+release tags per repository. Use --dangling or --registry to restrict scope.
113+
114+Only resources proven safe are deleted: every candidate gets an eligible,
115+protected, or unknown verdict, and protected or unknown candidates are
116+reported and left in place. A prune that deletes nothing succeeds.`,
119117 RunE: func(cmd *cobra.Command, _ []string) error {
120- client, isRemote, err := GetRemoteClient()
118+ client, _, err := resolveDaemonClient()
121119 if err != nil {
122120 return err
123121 }
124- if !isRemote {
125- return fmt.Errorf("images commands require a configured remote target")
126- }
127-
128122 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
129123 },
130124 }
131125
132- cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
126+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Run the full inventory and planning, then report without deleting")
133127 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
134128 "Number of previous non-latest tags to keep per repository (latest is always kept)")
135129 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
@@ -357,7 +351,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
357351 }
358352 }
359353
360- req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
354+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, false)
361355
362356 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
363357 if err != nil {
@@ -378,12 +372,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
378372 }
379373
380374 if pruneRegistry {
381- err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed))
375+ if err := cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed)); err != nil {
376+ return err
377+ }
378+ } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil {
382379 return err
383380 }
384381
385- err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
386- return err
382+ return cliRenderPruneSummary(out, resp.Plan)
387383 }
388384
389385 func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {
@@ -391,33 +387,34 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP
391387 return err
392388 }
393389
394- if pruneDangling {
395- images, err := client.ListImages(ctx)
396- if err != nil {
397- return fmt.Errorf("failed to list images: %w", err)
398- }
399-
400- danglingCount := 0
401- for _, img := range images {
402- if img.Dangling {
403- danglingCount++
404- }
405- }
390+ // A dry run executes the full inventory and planning path: only the
391+ // deletion step is skipped, so the report is the executed plan.
392+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true)
393+ resp, err := client.PruneImages(ctx, req)
394+ if err != nil {
395+ return fmt.Errorf("failed to plan image prune: %w", err)
396+ }
397+ if resp == nil {
398+ return fmt.Errorf("failed to plan image prune: empty response")
399+ }
406400
407- if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
408- return err
409- }
410- } else {
411- if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
401+ if pruneDangling {
402+ if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil {
412403 return err
413404 }
405+ } else if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
406+ return err
414407 }
415408
416409 if pruneRegistry {
417- return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases)
410+ if err := cliWritef(out, "Registry: latest + %d previous tags per repository\n", opts.KeepReleases); err != nil {
411+ return err
412+ }
413+ } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil {
414+ return err
418415 }
419416
420- return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
417+ return cliRenderPruneSummary(out, resp.Plan)
421418 }
422419
423420 func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {
@@ -561,11 +558,12 @@ func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]
561558 return kept
562559 }
563560
564-func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
561+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry, dryRun bool) dto.ImagePruneRequest {
565562 return dto.ImagePruneRequest{
566563 KeepLast: &keepReleases,
567564 PruneDangling: &pruneDangling,
568565 PruneRegistry: &pruneRegistry,
566+ DryRun: &dryRun,
569567 }
570568 }
571569
M internal/adapters/in/cli/images_test.go +35 -12
@@ -297,7 +297,18 @@ func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
297297
298298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299299 client := &imagesClientMock{
300- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
300+ pruneResp: &dto.ImagePruneResponse{
301+ Plan: dto.PruneSummary{
302+ Applied: false,
303+ Eligible: 2,
304+ Protected: 1,
305+ Candidates: []dto.PruneCandidate{
306+ {Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"},
307+ {Kind: "runtime-image", Ref: "sha256:b", Verdict: "eligible"},
308+ {Kind: "registry-tag", Ref: "app:v1", Verdict: "protected", Reasons: []string{"protected-active-service"}},
309+ },
310+ },
311+ },
301312 }
302313
303314 var out bytes.Buffer
@@ -307,15 +318,22 @@ func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
307318
308319 text := out.String()
309320 assert.Contains(t, text, "Dry run")
310- assert.Contains(t, text, "would prune 2 dangling runtime images")
311- assert.Contains(t, text, "would keep latest + 3 previous tags")
312- assert.Equal(t, 1, client.listImagesCalls)
313- assert.Equal(t, 0, client.pruneCalls)
321+ assert.Contains(t, text, "would delete 2 images")
322+ assert.Contains(t, text, "latest + 3 previous tags")
323+ assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
324+ assert.Contains(t, text, "protected-active-service")
325+ assert.Equal(t, 1, client.pruneCalls)
326+ assert.NotNil(t, client.lastPruneOpts.DryRun)
327+ assert.True(t, *client.lastPruneOpts.DryRun)
314328 }
315329
316330 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317331 client := &imagesClientMock{
318- listImagesResp: []dto.Image{{Dangling: true}},
332+ pruneResp: &dto.ImagePruneResponse{
333+ Plan: dto.PruneSummary{
334+ Candidates: []dto.PruneCandidate{{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"}},
335+ },
336+ },
319337 }
320338
321339 var out bytes.Buffer
@@ -324,14 +342,18 @@ func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
324342 require.NoError(t, err)
325343
326344 text := out.String()
327- assert.Contains(t, text, "would prune 1 dangling runtime images")
345+ assert.Contains(t, text, "would delete 1 images")
328346 assert.Contains(t, text, "Registry cleanup skipped")
329- assert.Equal(t, 0, client.pruneCalls)
347+ assert.Equal(t, 1, client.pruneCalls)
330348 }
331349
332350 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333351 client := &imagesClientMock{
334- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
352+ pruneResp: &dto.ImagePruneResponse{
353+ Plan: dto.PruneSummary{
354+ Candidates: []dto.PruneCandidate{{Kind: "registry-tag", Ref: "app:v1", Verdict: "eligible"}},
355+ },
356+ },
335357 }
336358
337359 var out bytes.Buffer
@@ -341,8 +363,8 @@ func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
341363
342364 text := out.String()
343365 assert.Contains(t, text, "Runtime cleanup skipped")
344- assert.Contains(t, text, "would keep latest + 5 previous tags")
345- assert.Equal(t, 0, client.pruneCalls)
366+ assert.Contains(t, text, "latest + 5 previous tags")
367+ assert.Equal(t, 1, client.pruneCalls)
346368 }
347369
348370 // ---------------------------------------------------------------------------
@@ -484,7 +506,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
484506 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485507
486508 client := &imagesClientMock{
487- listImagesResp: []dto.Image{{Dangling: true}},
509+ pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
488510 }
489511
490512 var out bytes.Buffer
@@ -493,6 +515,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
493515 require.NoError(t, err)
494516
495517 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518+ assert.Equal(t, 1, client.pruneCalls)
496519 }
497520
498521 // ---------------------------------------------------------------------------
M internal/adapters/in/cli/json_parity_test.go +0 -5
@@ -12,9 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1212 name string
1313 builder func() *cobra.Command
1414 }{
15- {"routes list", newRoutesListCmd},
16- {"routes status", newRoutesStatusCmd},
17- {"attachments list", newAttachmentsListCmd},
1815 {"secrets list", newSecretsListCmd},
1916 {"images list", newImagesListCmd},
2017 {"backup list", newBackupListCmd},
@@ -23,9 +20,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2320 {"backup volumes status", newVolumeBackupStatusCmd},
2421 {"auth token list", newTokenListCmd},
2522 {"remotes list", newRemotesListCmd},
26- {"pin list", newPinListCmd},
2723 {"config show", newConfigShowCmd},
28- {"routes show", newRoutesShowCmd},
2924 {"networks list", newNetworksListCmd},
3025 {"images tags", newImagesTagsCmd},
3126 {"tls status", newTLSStatusCmd},
M internal/adapters/in/cli/json_test.go +6 -169
@@ -14,24 +14,6 @@ import (
1414 "github.com/bnema/gordon/internal/domain"
1515 )
1616
17-func TestRoutesList_JSONFlag_Accepted(t *testing.T) {
18- cmd := newRoutesListCmd()
19- f := cmd.Flags().Lookup("json")
20- assert.NotNil(t, f)
21- if f != nil {
22- assert.Equal(t, "false", f.DefValue)
23- }
24-}
25-
26-func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) {
27- cmd := newAttachmentsListCmd()
28- f := cmd.Flags().Lookup("json")
29- assert.NotNil(t, f)
30- if f != nil {
31- assert.Equal(t, "false", f.DefValue)
32- }
33-}
34-
3517 func TestSecretsList_JSONFlag_Accepted(t *testing.T) {
3618 cmd := newSecretsListCmd()
3719 f := cmd.Flags().Lookup("json")
@@ -77,15 +59,6 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
7759 }
7860 }
7961
80-func TestPinList_JSONFlag_Accepted(t *testing.T) {
81- cmd := newPinListCmd()
82- f := cmd.Flags().Lookup("json")
83- assert.NotNil(t, f)
84- if f != nil {
85- assert.Equal(t, "false", f.DefValue)
86- }
87-}
88-
8962 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
9063 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
9164 images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}
@@ -113,7 +86,7 @@ func TestPinList_JSONShape_RoundTripsTags(t *testing.T) {
11386
11487 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
11588 startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
116- jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}}
89+ jobs := []domain.BackupJob{{ID: "b1", App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, StartedAt: startedAt}}
11790
11891 payload, err := json.Marshal(jobs)
11992 require.NoError(t, err)
@@ -122,7 +95,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
12295 require.NoError(t, json.Unmarshal(payload, &got))
12396 require.Len(t, got, 1)
12497 assert.Equal(t, "b1", got[0].ID)
125- assert.Equal(t, "app.example.com", got[0].Domain)
98+ assert.Equal(t, "shop", got[0].App)
99+ assert.Equal(t, "api", got[0].Service)
100+ assert.Equal(t, "orders", got[0].DBName)
126101 }
127102
128103 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
@@ -172,157 +147,19 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
172147 assert.True(t, got[0].InsecureTLS)
173148 }
174149
175-func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) {
176- payload := struct {
177- Target string `json:"target"`
178- Images []string `json:"images"`
179- }{
180- Target: "app.example.com",
181- Images: []string{"postgres:16"},
182- }
183-
184- encoded, err := json.Marshal(payload)
185- require.NoError(t, err)
186-
187- var got struct {
188- Target string `json:"target"`
189- Images []string `json:"images"`
190- }
191- require.NoError(t, json.Unmarshal(encoded, &got))
192- assert.Equal(t, payload.Target, got.Target)
193- assert.Equal(t, payload.Images, got.Images)
194-}
195-
196150 func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {
197- payload := struct {
198- Domain string `json:"domain"`
199- Keys []string `json:"keys"`
200- Attachments []remote.AttachmentSecrets `json:"attachments"`
201- }{
151+ payload := remote.SecretsListResult{
202152 Domain: "app.example.com",
203153 Keys: []string{"API_KEY"},
204- Attachments: []remote.AttachmentSecrets{{
205- Service: "postgres",
206- Keys: []string{"POSTGRES_PASSWORD"},
207- }},
208154 }
209155
210156 encoded, err := json.Marshal(payload)
211157 require.NoError(t, err)
212158
213- var got struct {
214- Domain string `json:"domain"`
215- Keys []string `json:"keys"`
216- Attachments []remote.AttachmentSecrets `json:"attachments"`
217- }
159+ var got remote.SecretsListResult
218160 require.NoError(t, json.Unmarshal(encoded, &got))
219161 assert.Equal(t, payload.Domain, got.Domain)
220162 assert.Equal(t, payload.Keys, got.Keys)
221- assert.Equal(t, payload.Attachments, got.Attachments)
222-}
223-
224-func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) {
225- payload := struct {
226- Domain string `json:"domain"`
227- Keys []string `json:"keys"`
228- Attachments []remote.AttachmentSecrets `json:"attachments"`
229- }{
230- Domain: "app.example.com",
231- Keys: []string{"API_KEY"},
232- Attachments: []remote.AttachmentSecrets{},
233- }
234-
235- encoded, err := json.Marshal(payload)
236- require.NoError(t, err)
237- assert.Contains(t, string(encoded), `"attachments":[]`)
238-
239- var got struct {
240- Domain string `json:"domain"`
241- Keys []string `json:"keys"`
242- Attachments []remote.AttachmentSecrets `json:"attachments"`
243- }
244- require.NoError(t, json.Unmarshal(encoded, &got))
245- assert.NotNil(t, got.Attachments)
246- assert.Empty(t, got.Attachments)
247-}
248-
249-func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
250- payload := []struct {
251- Domain string `json:"domain"`
252- Image string `json:"image"`
253- }{
254- {Domain: "app.example.com", Image: "app:latest"},
255- }
256-
257- encoded, err := json.Marshal(payload)
258- require.NoError(t, err)
259-
260- var got []struct {
261- Domain string `json:"domain"`
262- Image string `json:"image"`
263- }
264- require.NoError(t, json.Unmarshal(encoded, &got))
265- require.Len(t, got, 1)
266- assert.Equal(t, payload[0], got[0])
267-}
268-
269-func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) {
270- cmd := newRoutesStatusCmd()
271- f := cmd.Flags().Lookup("json")
272- assert.NotNil(t, f)
273- if f != nil {
274- assert.Equal(t, "false", f.DefValue)
275- }
276-}
277-
278-func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) {
279- payload := []routeListSection{{
280- Kind: "local",
281- Name: "local",
282- Routes: []routeListItem{{
283- Domain: "app.local",
284- Image: "myapp:latest",
285- }},
286- }}
287-
288- encoded, err := json.Marshal(payload)
289- require.NoError(t, err)
290-
291- var got []routeListSection
292- require.NoError(t, json.Unmarshal(encoded, &got))
293- require.Len(t, got, 1)
294- assert.Equal(t, "local", got[0].Kind)
295- assert.Equal(t, "app.local", got[0].Routes[0].Domain)
296-}
297-
298-func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) {
299- payload := []routeStatusSection{{
300- Kind: "remote",
301- Name: "igor",
302- URL: "https://gordon.supri.xyz",
303- Routes: []routeStatusItem{{
304- Domain: "grafana.supri.xyz",
305- Image: "grafana",
306- ContainerStatus: "running",
307- HTTPStatus: 200,
308- Network: "gordon-shared",
309- Attachments: []routeStatusAttachment{{
310- Name: "prometheus",
311- Image: "prometheus:v5",
312- Status: "running",
313- }},
314- }},
315- }}
316-
317- encoded, err := json.Marshal(payload)
318- require.NoError(t, err)
319-
320- var got []routeStatusSection
321- require.NoError(t, json.Unmarshal(encoded, &got))
322- require.Len(t, got, 1)
323- assert.Equal(t, "igor", got[0].Name)
324- assert.Equal(t, 200, got[0].Routes[0].HTTPStatus)
325- assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name)
326163 }
327164
328165 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
M internal/adapters/in/cli/local.go +2 -2
@@ -49,7 +49,7 @@ func (l *LocalServices) HasInternalTLS() bool {
4949
5050 // GetLocalServices creates local services for CLI operations.
5151 // It loads the config and initializes services without starting the server.
52-func GetLocalServices(configPath string) (*LocalServices, error) {
52+func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
5353 // Set up viper with defaults
5454 v := viper.New()
5555 v.SetDefault("server.port", 8088)
@@ -57,7 +57,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
5757 v.SetDefault("server.data_dir", app.DefaultDataDir())
5858
5959 // Configure viper with config file paths
60- app.ConfigureViper(v, configPath)
60+ app.ConfigureViper(v, cliConfigPath)
6161
6262 // Read config file
6363 if err := v.ReadInConfig(); err != nil {
M internal/adapters/in/cli/mocks/mock_control_plane.go +570 -1398
@@ -40,164 +40,55 @@ func (_m *MockControlPlane) EXPECT() *MockControlPlane_Expecter {
4040 return &MockControlPlane_Expecter{mock: &_m.Mock}
4141 }
4242
43-// AddAttachment provides a mock function for the type MockControlPlane
44-func (_mock *MockControlPlane) AddAttachment(ctx context.Context, domainOrGroup string, image string) error {
45- ret := _mock.Called(ctx, domainOrGroup, image)
43+// ApplyApp provides a mock function for the type MockControlPlane
44+func (_mock *MockControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
45+ ret := _mock.Called(ctx, req)
4646
4747 if len(ret) == 0 {
48- panic("no return value specified for AddAttachment")
49- }
50-
51- var r0 error
52- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
53- r0 = returnFunc(ctx, domainOrGroup, image)
54- } else {
55- r0 = ret.Error(0)
48+ panic("no return value specified for ApplyApp")
5649 }
57- return r0
58-}
59-
60-// MockControlPlane_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment'
61-type MockControlPlane_AddAttachment_Call struct {
62- *mock.Call
63-}
64-
65-// AddAttachment is a helper method to define mock.On call
66-// - ctx context.Context
67-// - domainOrGroup string
68-// - image string
69-func (_e *MockControlPlane_Expecter) AddAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_AddAttachment_Call {
70- return &MockControlPlane_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
71-}
72-
73-func (_c *MockControlPlane_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockControlPlane_AddAttachment_Call {
74- _c.Call.Run(func(args mock.Arguments) {
75- var arg0 context.Context
76- if args[0] != nil {
77- arg0 = args[0].(context.Context)
78- }
79- var arg1 string
80- if args[1] != nil {
81- arg1 = args[1].(string)
82- }
83- var arg2 string
84- if args[2] != nil {
85- arg2 = args[2].(string)
86- }
87- run(
88- arg0,
89- arg1,
90- arg2,
91- )
92- })
93- return _c
94-}
95-
96-func (_c *MockControlPlane_AddAttachment_Call) Return(err error) *MockControlPlane_AddAttachment_Call {
97- _c.Call.Return(err)
98- return _c
99-}
100-
101-func (_c *MockControlPlane_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockControlPlane_AddAttachment_Call {
102- _c.Call.Return(run)
103- return _c
104-}
105-
106-// AddAutoRouteAllowedDomain provides a mock function for the type MockControlPlane
107-func (_mock *MockControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
108- ret := _mock.Called(ctx, pattern)
10950
110- if len(ret) == 0 {
111- panic("no return value specified for AddAutoRouteAllowedDomain")
51+ var r0 *dto.AppApplyResponse
52+ var r1 error
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.AppApplyRequest) (*dto.AppApplyResponse, error)); ok {
54+ return returnFunc(ctx, req)
11255 }
113-
114- var r0 error
115- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
116- r0 = returnFunc(ctx, pattern)
56+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.AppApplyRequest) *dto.AppApplyResponse); ok {
57+ r0 = returnFunc(ctx, req)
11758 } else {
118- r0 = ret.Error(0)
119- }
120- return r0
121-}
122-
123-// MockControlPlane_AddAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAutoRouteAllowedDomain'
124-type MockControlPlane_AddAutoRouteAllowedDomain_Call struct {
125- *mock.Call
126-}
127-
128-// AddAutoRouteAllowedDomain is a helper method to define mock.On call
129-// - ctx context.Context
130-// - pattern string
131-func (_e *MockControlPlane_Expecter) AddAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
132- return &MockControlPlane_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
133-}
134-
135-func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
136- _c.Call.Run(func(args mock.Arguments) {
137- var arg0 context.Context
138- if args[0] != nil {
139- arg0 = args[0].(context.Context)
140- }
141- var arg1 string
142- if args[1] != nil {
143- arg1 = args[1].(string)
59+ if ret.Get(0) != nil {
60+ r0 = ret.Get(0).(*dto.AppApplyResponse)
14461 }
145- run(
146- arg0,
147- arg1,
148- )
149- })
150- return _c
151-}
152-
153-func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) Return(err error) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
154- _c.Call.Return(err)
155- return _c
156-}
157-
158-func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
159- _c.Call.Return(run)
160- return _c
161-}
162-
163-// AddRoute provides a mock function for the type MockControlPlane
164-func (_mock *MockControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
165- ret := _mock.Called(ctx, route)
166-
167- if len(ret) == 0 {
168- panic("no return value specified for AddRoute")
16962 }
170-
171- var r0 error
172- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {
173- r0 = returnFunc(ctx, route)
63+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.AppApplyRequest) error); ok {
64+ r1 = returnFunc(ctx, req)
17465 } else {
175- r0 = ret.Error(0)
66+ r1 = ret.Error(1)
17667 }
177- return r0
68+ return r0, r1
17869 }
17970
180-// MockControlPlane_AddRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRoute'
181-type MockControlPlane_AddRoute_Call struct {
71+// MockControlPlane_ApplyApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyApp'
72+type MockControlPlane_ApplyApp_Call struct {
18273 *mock.Call
18374 }
18475
185-// AddRoute is a helper method to define mock.On call
76+// ApplyApp is a helper method to define mock.On call
18677 // - ctx context.Context
187-// - route domain.Route
188-func (_e *MockControlPlane_Expecter) AddRoute(ctx any, route any) *MockControlPlane_AddRoute_Call {
189- return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
78+// - req dto.AppApplyRequest
79+func (_e *MockControlPlane_Expecter) ApplyApp(ctx any, req any) *MockControlPlane_ApplyApp_Call {
80+ return &MockControlPlane_ApplyApp_Call{Call: _e.mock.On("ApplyApp", ctx, req)}
19081 }
19182
192-func (_c *MockControlPlane_AddRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockControlPlane_AddRoute_Call {
83+func (_c *MockControlPlane_ApplyApp_Call) Run(run func(ctx context.Context, req dto.AppApplyRequest)) *MockControlPlane_ApplyApp_Call {
19384 _c.Call.Run(func(args mock.Arguments) {
19485 var arg0 context.Context
19586 if args[0] != nil {
19687 arg0 = args[0].(context.Context)
19788 }
198- var arg1 domain.Route
89+ var arg1 dto.AppApplyRequest
19990 if args[1] != nil {
200- arg1 = args[1].(domain.Route)
91+ arg1 = args[1].(dto.AppApplyRequest)
20192 }
20293 run(
20394 arg0,
@@ -207,12 +98,12 @@ func (_c *MockControlPlane_AddRoute_Call) Run(run func(ctx context.Context, rout
20798 return _c
20899 }
209100
210-func (_c *MockControlPlane_AddRoute_Call) Return(err error) *MockControlPlane_AddRoute_Call {
211- _c.Call.Return(err)
101+func (_c *MockControlPlane_ApplyApp_Call) Return(appApplyResponse *dto.AppApplyResponse, err error) *MockControlPlane_ApplyApp_Call {
102+ _c.Call.Return(appApplyResponse, err)
212103 return _c
213104 }
214105
215-func (_c *MockControlPlane_AddRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockControlPlane_AddRoute_Call {
106+func (_c *MockControlPlane_ApplyApp_Call) RunAndReturn(run func(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)) *MockControlPlane_ApplyApp_Call {
216107 _c.Call.Return(run)
217108 return _c
218109 }
@@ -279,180 +170,37 @@ func (_c *MockControlPlane_BackupStatus_Call) RunAndReturn(run func(ctx context.
279170 return _c
280171 }
281172
282-// Bootstrap provides a mock function for the type MockControlPlane
283-func (_mock *MockControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
284- ret := _mock.Called(ctx, req)
285-
286- if len(ret) == 0 {
287- panic("no return value specified for Bootstrap")
288- }
289-
290- var r0 *dto.BootstrapResponse
291- var r1 error
292- if returnFunc, ok := ret.Get(0).(func(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error)); ok {
293- return returnFunc(ctx, req)
294- }
295- if returnFunc, ok := ret.Get(0).(func(context.Context, dto.BootstrapRequest) *dto.BootstrapResponse); ok {
296- r0 = returnFunc(ctx, req)
297- } else {
298- if ret.Get(0) != nil {
299- r0 = ret.Get(0).(*dto.BootstrapResponse)
300- }
301- }
302- if returnFunc, ok := ret.Get(1).(func(context.Context, dto.BootstrapRequest) error); ok {
303- r1 = returnFunc(ctx, req)
304- } else {
305- r1 = ret.Error(1)
306- }
307- return r0, r1
308-}
309-
310-// MockControlPlane_Bootstrap_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bootstrap'
311-type MockControlPlane_Bootstrap_Call struct {
312- *mock.Call
313-}
314-
315-// Bootstrap is a helper method to define mock.On call
316-// - ctx context.Context
317-// - req dto.BootstrapRequest
318-func (_e *MockControlPlane_Expecter) Bootstrap(ctx any, req any) *MockControlPlane_Bootstrap_Call {
319- return &MockControlPlane_Bootstrap_Call{Call: _e.mock.On("Bootstrap", ctx, req)}
320-}
321-
322-func (_c *MockControlPlane_Bootstrap_Call) Run(run func(ctx context.Context, req dto.BootstrapRequest)) *MockControlPlane_Bootstrap_Call {
323- _c.Call.Run(func(args mock.Arguments) {
324- var arg0 context.Context
325- if args[0] != nil {
326- arg0 = args[0].(context.Context)
327- }
328- var arg1 dto.BootstrapRequest
329- if args[1] != nil {
330- arg1 = args[1].(dto.BootstrapRequest)
331- }
332- run(
333- arg0,
334- arg1,
335- )
336- })
337- return _c
338-}
339-
340-func (_c *MockControlPlane_Bootstrap_Call) Return(bootstrapResponse *dto.BootstrapResponse, err error) *MockControlPlane_Bootstrap_Call {
341- _c.Call.Return(bootstrapResponse, err)
342- return _c
343-}
344-
345-func (_c *MockControlPlane_Bootstrap_Call) RunAndReturn(run func(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)) *MockControlPlane_Bootstrap_Call {
346- _c.Call.Return(run)
347- return _c
348-}
349-
350-// CleanupOrphanedAttachments provides a mock function for the type MockControlPlane
351-func (_mock *MockControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
352- ret := _mock.Called(ctx, owner, stop)
353-
354- if len(ret) == 0 {
355- panic("no return value specified for CleanupOrphanedAttachments")
356- }
357-
358- var r0 *domain.CleanupReport
359- var r1 error
360- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*domain.CleanupReport, error)); ok {
361- return returnFunc(ctx, owner, stop)
362- }
363- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *domain.CleanupReport); ok {
364- r0 = returnFunc(ctx, owner, stop)
365- } else {
366- if ret.Get(0) != nil {
367- r0 = ret.Get(0).(*domain.CleanupReport)
368- }
369- }
370- if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
371- r1 = returnFunc(ctx, owner, stop)
372- } else {
373- r1 = ret.Error(1)
374- }
375- return r0, r1
376-}
377-
378-// MockControlPlane_CleanupOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupOrphanedAttachments'
379-type MockControlPlane_CleanupOrphanedAttachments_Call struct {
380- *mock.Call
381-}
382-
383-// CleanupOrphanedAttachments is a helper method to define mock.On call
384-// - ctx context.Context
385-// - owner string
386-// - stop bool
387-func (_e *MockControlPlane_Expecter) CleanupOrphanedAttachments(ctx any, owner any, stop any) *MockControlPlane_CleanupOrphanedAttachments_Call {
388- return &MockControlPlane_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}
389-}
390-
391-func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) Run(run func(ctx context.Context, owner string, stop bool)) *MockControlPlane_CleanupOrphanedAttachments_Call {
392- _c.Call.Run(func(args mock.Arguments) {
393- var arg0 context.Context
394- if args[0] != nil {
395- arg0 = args[0].(context.Context)
396- }
397- var arg1 string
398- if args[1] != nil {
399- arg1 = args[1].(string)
400- }
401- var arg2 bool
402- if args[2] != nil {
403- arg2 = args[2].(bool)
404- }
405- run(
406- arg0,
407- arg1,
408- arg2,
409- )
410- })
411- return _c
412-}
413-
414-func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockControlPlane_CleanupOrphanedAttachments_Call {
415- _c.Call.Return(cleanupReport, err)
416- return _c
417-}
418-
419-func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)) *MockControlPlane_CleanupOrphanedAttachments_Call {
420- _c.Call.Return(run)
421- return _c
422-}
423-
424-// DeleteAttachmentSecret provides a mock function for the type MockControlPlane
425-func (_mock *MockControlPlane) DeleteAttachmentSecret(ctx context.Context, domain1 string, service string, key string) error {
426- ret := _mock.Called(ctx, domain1, service, key)
173+// DeleteAppSecret provides a mock function for the type MockControlPlane
174+func (_mock *MockControlPlane) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error {
175+ ret := _mock.Called(ctx, app, req)
427176
428177 if len(ret) == 0 {
429- panic("no return value specified for DeleteAttachmentSecret")
178+ panic("no return value specified for DeleteAppSecret")
430179 }
431180
432181 var r0 error
433- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
434- r0 = returnFunc(ctx, domain1, service, key)
182+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretDeleteRequest) error); ok {
183+ r0 = returnFunc(ctx, app, req)
435184 } else {
436185 r0 = ret.Error(0)
437186 }
438187 return r0
439188 }
440189
441-// MockControlPlane_DeleteAttachmentSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachmentSecret'
442-type MockControlPlane_DeleteAttachmentSecret_Call struct {
190+// MockControlPlane_DeleteAppSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAppSecret'
191+type MockControlPlane_DeleteAppSecret_Call struct {
443192 *mock.Call
444193 }
445194
446-// DeleteAttachmentSecret is a helper method to define mock.On call
195+// DeleteAppSecret is a helper method to define mock.On call
447196 // - ctx context.Context
448-// - domain1 string
449-// - service string
450-// - key string
451-func (_e *MockControlPlane_Expecter) DeleteAttachmentSecret(ctx any, domain1 any, service any, key any) *MockControlPlane_DeleteAttachmentSecret_Call {
452- return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)}
197+// - app string
198+// - req dto.AppSecretDeleteRequest
199+func (_e *MockControlPlane_Expecter) DeleteAppSecret(ctx any, app any, req any) *MockControlPlane_DeleteAppSecret_Call {
200+ return &MockControlPlane_DeleteAppSecret_Call{Call: _e.mock.On("DeleteAppSecret", ctx, app, req)}
453201 }
454202
455-func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Run(run func(ctx context.Context, domain1 string, service string, key string)) *MockControlPlane_DeleteAttachmentSecret_Call {
203+func (_c *MockControlPlane_DeleteAppSecret_Call) Run(run func(ctx context.Context, app string, req dto.AppSecretDeleteRequest)) *MockControlPlane_DeleteAppSecret_Call {
456204 _c.Call.Run(func(args mock.Arguments) {
457205 var arg0 context.Context
458206 if args[0] != nil {
@@ -462,30 +210,25 @@ func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Run(run func(ctx context
462210 if args[1] != nil {
463211 arg1 = args[1].(string)
464212 }
465- var arg2 string
213+ var arg2 dto.AppSecretDeleteRequest
466214 if args[2] != nil {
467- arg2 = args[2].(string)
468- }
469- var arg3 string
470- if args[3] != nil {
471- arg3 = args[3].(string)
215+ arg2 = args[2].(dto.AppSecretDeleteRequest)
472216 }
473217 run(
474218 arg0,
475219 arg1,
476220 arg2,
477- arg3,
478221 )
479222 })
480223 return _c
481224 }
482225
483-func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Return(err error) *MockControlPlane_DeleteAttachmentSecret_Call {
226+func (_c *MockControlPlane_DeleteAppSecret_Call) Return(err error) *MockControlPlane_DeleteAppSecret_Call {
484227 _c.Call.Return(err)
485228 return _c
486229 }
487230
488-func (_c *MockControlPlane_DeleteAttachmentSecret_Call) RunAndReturn(run func(ctx context.Context, domain1 string, service string, key string) error) *MockControlPlane_DeleteAttachmentSecret_Call {
231+func (_c *MockControlPlane_DeleteAppSecret_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error) *MockControlPlane_DeleteAppSecret_Call {
489232 _c.Call.Return(run)
490233 return _c
491234 }
@@ -553,631 +296,127 @@ func (_c *MockControlPlane_DeleteSecret_Call) RunAndReturn(run func(ctx context.
553296 return _c
554297 }
555298
556-// Deploy provides a mock function for the type MockControlPlane
557-func (_mock *MockControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
558- ret := _mock.Called(ctx, deployDomain)
559-
560- if len(ret) == 0 {
561- panic("no return value specified for Deploy")
562- }
563-
564- var r0 *remote.DeployResult
565- var r1 error
566- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*remote.DeployResult, error)); ok {
567- return returnFunc(ctx, deployDomain)
568- }
569- if returnFunc, ok := ret.Get(0).(func(context.Context, string) *remote.DeployResult); ok {
570- r0 = returnFunc(ctx, deployDomain)
571- } else {
572- if ret.Get(0) != nil {
573- r0 = ret.Get(0).(*remote.DeployResult)
574- }
575- }
576- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
577- r1 = returnFunc(ctx, deployDomain)
578- } else {
579- r1 = ret.Error(1)
580- }
581- return r0, r1
582-}
583-
584-// MockControlPlane_Deploy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deploy'
585-type MockControlPlane_Deploy_Call struct {
586- *mock.Call
587-}
588-
589-// Deploy is a helper method to define mock.On call
590-// - ctx context.Context
591-// - deployDomain string
592-func (_e *MockControlPlane_Expecter) Deploy(ctx any, deployDomain any) *MockControlPlane_Deploy_Call {
593- return &MockControlPlane_Deploy_Call{Call: _e.mock.On("Deploy", ctx, deployDomain)}
594-}
595-
596-func (_c *MockControlPlane_Deploy_Call) Run(run func(ctx context.Context, deployDomain string)) *MockControlPlane_Deploy_Call {
597- _c.Call.Run(func(args mock.Arguments) {
598- var arg0 context.Context
599- if args[0] != nil {
600- arg0 = args[0].(context.Context)
601- }
602- var arg1 string
603- if args[1] != nil {
604- arg1 = args[1].(string)
605- }
606- run(
607- arg0,
608- arg1,
609- )
610- })
611- return _c
612-}
613-
614-func (_c *MockControlPlane_Deploy_Call) Return(deployResult *remote.DeployResult, err error) *MockControlPlane_Deploy_Call {
615- _c.Call.Return(deployResult, err)
616- return _c
617-}
618-
619-func (_c *MockControlPlane_Deploy_Call) RunAndReturn(run func(ctx context.Context, deployDomain string) (*remote.DeployResult, error)) *MockControlPlane_Deploy_Call {
620- _c.Call.Return(run)
621- return _c
622-}
623-
624-// DeployIntent provides a mock function for the type MockControlPlane
625-func (_mock *MockControlPlane) DeployIntent(ctx context.Context, imageName string) error {
626- ret := _mock.Called(ctx, imageName)
299+// DeployApp provides a mock function for the type MockControlPlane
300+func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
301+ ret := _mock.Called(ctx, app, req)
627302
628303 if len(ret) == 0 {
629- panic("no return value specified for DeployIntent")
304+ panic("no return value specified for DeployApp")
630305 }
631306
632- var r0 error
633- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
634- r0 = returnFunc(ctx, imageName)
635- } else {
636- r0 = ret.Error(0)
307+ var r0 *dto.AppDeployResponse
308+ var r1 string
309+ var r2 error
310+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)); ok {
311+ return returnFunc(ctx, app, req)
637312 }
638- return r0
639-}
640-
641-// MockControlPlane_DeployIntent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployIntent'
642-type MockControlPlane_DeployIntent_Call struct {
643- *mock.Call
644-}
645-
646-// DeployIntent is a helper method to define mock.On call
647-// - ctx context.Context
648-// - imageName string
649-func (_e *MockControlPlane_Expecter) DeployIntent(ctx any, imageName any) *MockControlPlane_DeployIntent_Call {
650- return &MockControlPlane_DeployIntent_Call{Call: _e.mock.On("DeployIntent", ctx, imageName)}
651-}
652-
653-func (_c *MockControlPlane_DeployIntent_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_DeployIntent_Call {
654- _c.Call.Run(func(args mock.Arguments) {
655- var arg0 context.Context
656- if args[0] != nil {
657- arg0 = args[0].(context.Context)
658- }
659- var arg1 string
660- if args[1] != nil {
661- arg1 = args[1].(string)
662- }
663- run(
664- arg0,
665- arg1,
666- )
667- })
668- return _c
669-}
670-
671-func (_c *MockControlPlane_DeployIntent_Call) Return(err error) *MockControlPlane_DeployIntent_Call {
672- _c.Call.Return(err)
673- return _c
674-}
675-
676-func (_c *MockControlPlane_DeployIntent_Call) RunAndReturn(run func(ctx context.Context, imageName string) error) *MockControlPlane_DeployIntent_Call {
677- _c.Call.Return(run)
678- return _c
679-}
680-
681-// DetectDatabases provides a mock function for the type MockControlPlane
682-func (_mock *MockControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
683- ret := _mock.Called(ctx, backupDomain)
684-
685- if len(ret) == 0 {
686- panic("no return value specified for DetectDatabases")
687- }
688-
689- var r0 []dto.DatabaseInfo
690- var r1 error
691- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.DatabaseInfo, error)); ok {
692- return returnFunc(ctx, backupDomain)
693- }
694- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.DatabaseInfo); ok {
695- r0 = returnFunc(ctx, backupDomain)
696- } else {
697- if ret.Get(0) != nil {
698- r0 = ret.Get(0).([]dto.DatabaseInfo)
699- }
700- }
701- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
702- r1 = returnFunc(ctx, backupDomain)
703- } else {
704- r1 = ret.Error(1)
705- }
706- return r0, r1
707-}
708-
709-// MockControlPlane_DetectDatabases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectDatabases'
710-type MockControlPlane_DetectDatabases_Call struct {
711- *mock.Call
712-}
713-
714-// DetectDatabases is a helper method to define mock.On call
715-// - ctx context.Context
716-// - backupDomain string
717-func (_e *MockControlPlane_Expecter) DetectDatabases(ctx any, backupDomain any) *MockControlPlane_DetectDatabases_Call {
718- return &MockControlPlane_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, backupDomain)}
719-}
720-
721-func (_c *MockControlPlane_DetectDatabases_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_DetectDatabases_Call {
722- _c.Call.Run(func(args mock.Arguments) {
723- var arg0 context.Context
724- if args[0] != nil {
725- arg0 = args[0].(context.Context)
726- }
727- var arg1 string
728- if args[1] != nil {
729- arg1 = args[1].(string)
730- }
731- run(
732- arg0,
733- arg1,
734- )
735- })
736- return _c
737-}
738-
739-func (_c *MockControlPlane_DetectDatabases_Call) Return(databaseInfos []dto.DatabaseInfo, err error) *MockControlPlane_DetectDatabases_Call {
740- _c.Call.Return(databaseInfos, err)
741- return _c
742-}
743-
744-func (_c *MockControlPlane_DetectDatabases_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)) *MockControlPlane_DetectDatabases_Call {
745- _c.Call.Return(run)
746- return _c
747-}
748-
749-// FindAttachmentTargetsByImage provides a mock function for the type MockControlPlane
750-func (_mock *MockControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
751- ret := _mock.Called(ctx, imageName)
752-
753- if len(ret) == 0 {
754- panic("no return value specified for FindAttachmentTargetsByImage")
755- }
756-
757- var r0 []string
758- var r1 error
759- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
760- return returnFunc(ctx, imageName)
761- }
762- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
763- r0 = returnFunc(ctx, imageName)
764- } else {
765- if ret.Get(0) != nil {
766- r0 = ret.Get(0).([]string)
767- }
768- }
769- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
770- r1 = returnFunc(ctx, imageName)
771- } else {
772- r1 = ret.Error(1)
773- }
774- return r0, r1
775-}
776-
777-// MockControlPlane_FindAttachmentTargetsByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindAttachmentTargetsByImage'
778-type MockControlPlane_FindAttachmentTargetsByImage_Call struct {
779- *mock.Call
780-}
781-
782-// FindAttachmentTargetsByImage is a helper method to define mock.On call
783-// - ctx context.Context
784-// - imageName string
785-func (_e *MockControlPlane_Expecter) FindAttachmentTargetsByImage(ctx any, imageName any) *MockControlPlane_FindAttachmentTargetsByImage_Call {
786- return &MockControlPlane_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}
787-}
788-
789-func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_FindAttachmentTargetsByImage_Call {
790- _c.Call.Run(func(args mock.Arguments) {
791- var arg0 context.Context
792- if args[0] != nil {
793- arg0 = args[0].(context.Context)
794- }
795- var arg1 string
796- if args[1] != nil {
797- arg1 = args[1].(string)
798- }
799- run(
800- arg0,
801- arg1,
802- )
803- })
804- return _c
805-}
806-
807-func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) Return(strings []string, err error) *MockControlPlane_FindAttachmentTargetsByImage_Call {
808- _c.Call.Return(strings, err)
809- return _c
810-}
811-
812-func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) ([]string, error)) *MockControlPlane_FindAttachmentTargetsByImage_Call {
813- _c.Call.Return(run)
814- return _c
815-}
816-
817-// FindRoutesByImage provides a mock function for the type MockControlPlane
818-func (_mock *MockControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
819- ret := _mock.Called(ctx, imageName)
820-
821- if len(ret) == 0 {
822- panic("no return value specified for FindRoutesByImage")
823- }
824-
825- var r0 []domain.Route
826- var r1 error
827- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.Route, error)); ok {
828- return returnFunc(ctx, imageName)
829- }
830- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok {
831- r0 = returnFunc(ctx, imageName)
832- } else {
833- if ret.Get(0) != nil {
834- r0 = ret.Get(0).([]domain.Route)
835- }
836- }
837- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
838- r1 = returnFunc(ctx, imageName)
839- } else {
840- r1 = ret.Error(1)
841- }
842- return r0, r1
843-}
844-
845-// MockControlPlane_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage'
846-type MockControlPlane_FindRoutesByImage_Call struct {
847- *mock.Call
848-}
849-
850-// FindRoutesByImage is a helper method to define mock.On call
851-// - ctx context.Context
852-// - imageName string
853-func (_e *MockControlPlane_Expecter) FindRoutesByImage(ctx any, imageName any) *MockControlPlane_FindRoutesByImage_Call {
854- return &MockControlPlane_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
855-}
856-
857-func (_c *MockControlPlane_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_FindRoutesByImage_Call {
858- _c.Call.Run(func(args mock.Arguments) {
859- var arg0 context.Context
860- if args[0] != nil {
861- arg0 = args[0].(context.Context)
862- }
863- var arg1 string
864- if args[1] != nil {
865- arg1 = args[1].(string)
866- }
867- run(
868- arg0,
869- arg1,
870- )
871- })
872- return _c
873-}
874-
875-func (_c *MockControlPlane_FindRoutesByImage_Call) Return(routes []domain.Route, err error) *MockControlPlane_FindRoutesByImage_Call {
876- _c.Call.Return(routes, err)
877- return _c
878-}
879-
880-func (_c *MockControlPlane_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) ([]domain.Route, error)) *MockControlPlane_FindRoutesByImage_Call {
881- _c.Call.Return(run)
882- return _c
883-}
884-
885-// GetAllAttachmentsConfig provides a mock function for the type MockControlPlane
886-func (_mock *MockControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
887- ret := _mock.Called(ctx)
888-
889- if len(ret) == 0 {
890- panic("no return value specified for GetAllAttachmentsConfig")
891- }
892-
893- var r0 map[string][]string
894- var r1 error
895- if returnFunc, ok := ret.Get(0).(func(context.Context) (map[string][]string, error)); ok {
896- return returnFunc(ctx)
897- }
898- if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok {
899- r0 = returnFunc(ctx)
900- } else {
901- if ret.Get(0) != nil {
902- r0 = ret.Get(0).(map[string][]string)
903- }
904- }
905- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
906- r1 = returnFunc(ctx)
907- } else {
908- r1 = ret.Error(1)
909- }
910- return r0, r1
911-}
912-
913-// MockControlPlane_GetAllAttachmentsConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachmentsConfig'
914-type MockControlPlane_GetAllAttachmentsConfig_Call struct {
915- *mock.Call
916-}
917-
918-// GetAllAttachmentsConfig is a helper method to define mock.On call
919-// - ctx context.Context
920-func (_e *MockControlPlane_Expecter) GetAllAttachmentsConfig(ctx any) *MockControlPlane_GetAllAttachmentsConfig_Call {
921- return &MockControlPlane_GetAllAttachmentsConfig_Call{Call: _e.mock.On("GetAllAttachmentsConfig", ctx)}
922-}
923-
924-func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetAllAttachmentsConfig_Call {
925- _c.Call.Run(func(args mock.Arguments) {
926- var arg0 context.Context
927- if args[0] != nil {
928- arg0 = args[0].(context.Context)
929- }
930- run(
931- arg0,
932- )
933- })
934- return _c
935-}
936-
937-func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) Return(stringToStrings map[string][]string, err error) *MockControlPlane_GetAllAttachmentsConfig_Call {
938- _c.Call.Return(stringToStrings, err)
939- return _c
940-}
941-
942-func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) RunAndReturn(run func(ctx context.Context) (map[string][]string, error)) *MockControlPlane_GetAllAttachmentsConfig_Call {
943- _c.Call.Return(run)
944- return _c
945-}
946-
947-// GetAttachmentsConfig provides a mock function for the type MockControlPlane
948-func (_mock *MockControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
949- ret := _mock.Called(ctx, domainOrGroup)
950-
951- if len(ret) == 0 {
952- panic("no return value specified for GetAttachmentsConfig")
953- }
954-
955- var r0 []string
956- var r1 error
957- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
958- return returnFunc(ctx, domainOrGroup)
959- }
960- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
961- r0 = returnFunc(ctx, domainOrGroup)
962- } else {
963- if ret.Get(0) != nil {
964- r0 = ret.Get(0).([]string)
965- }
966- }
967- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
968- r1 = returnFunc(ctx, domainOrGroup)
969- } else {
970- r1 = ret.Error(1)
971- }
972- return r0, r1
973-}
974-
975-// MockControlPlane_GetAttachmentsConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsConfig'
976-type MockControlPlane_GetAttachmentsConfig_Call struct {
977- *mock.Call
978-}
979-
980-// GetAttachmentsConfig is a helper method to define mock.On call
981-// - ctx context.Context
982-// - domainOrGroup string
983-func (_e *MockControlPlane_Expecter) GetAttachmentsConfig(ctx any, domainOrGroup any) *MockControlPlane_GetAttachmentsConfig_Call {
984- return &MockControlPlane_GetAttachmentsConfig_Call{Call: _e.mock.On("GetAttachmentsConfig", ctx, domainOrGroup)}
985-}
986-
987-func (_c *MockControlPlane_GetAttachmentsConfig_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockControlPlane_GetAttachmentsConfig_Call {
988- _c.Call.Run(func(args mock.Arguments) {
989- var arg0 context.Context
990- if args[0] != nil {
991- arg0 = args[0].(context.Context)
992- }
993- var arg1 string
994- if args[1] != nil {
995- arg1 = args[1].(string)
996- }
997- run(
998- arg0,
999- arg1,
1000- )
1001- })
1002- return _c
1003-}
1004-
1005-func (_c *MockControlPlane_GetAttachmentsConfig_Call) Return(strings []string, err error) *MockControlPlane_GetAttachmentsConfig_Call {
1006- _c.Call.Return(strings, err)
1007- return _c
1008-}
1009-
1010-func (_c *MockControlPlane_GetAttachmentsConfig_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockControlPlane_GetAttachmentsConfig_Call {
1011- _c.Call.Return(run)
1012- return _c
1013-}
1014-
1015-// GetAutoRouteAllowedDomains provides a mock function for the type MockControlPlane
1016-func (_mock *MockControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
1017- ret := _mock.Called(ctx)
1018-
1019- if len(ret) == 0 {
1020- panic("no return value specified for GetAutoRouteAllowedDomains")
1021- }
1022-
1023- var r0 []string
1024- var r1 error
1025- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
1026- return returnFunc(ctx)
1027- }
1028- if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
1029- r0 = returnFunc(ctx)
1030- } else {
1031- if ret.Get(0) != nil {
1032- r0 = ret.Get(0).([]string)
1033- }
1034- }
1035- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1036- r1 = returnFunc(ctx)
1037- } else {
1038- r1 = ret.Error(1)
1039- }
1040- return r0, r1
1041-}
1042-
1043-// MockControlPlane_GetAutoRouteAllowedDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAutoRouteAllowedDomains'
1044-type MockControlPlane_GetAutoRouteAllowedDomains_Call struct {
1045- *mock.Call
1046-}
1047-
1048-// GetAutoRouteAllowedDomains is a helper method to define mock.On call
1049-// - ctx context.Context
1050-func (_e *MockControlPlane_Expecter) GetAutoRouteAllowedDomains(ctx any) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
1051- return &MockControlPlane_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
1052-}
1053-
1054-func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
1055- _c.Call.Run(func(args mock.Arguments) {
1056- var arg0 context.Context
1057- if args[0] != nil {
1058- arg0 = args[0].(context.Context)
1059- }
1060- run(
1061- arg0,
1062- )
1063- })
1064- return _c
1065-}
1066-
1067-func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) Return(strings []string, err error) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
1068- _c.Call.Return(strings, err)
1069- return _c
1070-}
1071-
1072-func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
1073- _c.Call.Return(run)
1074- return _c
1075-}
1076-
1077-// GetConfig provides a mock function for the type MockControlPlane
1078-func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
1079- ret := _mock.Called(ctx)
1080-
1081- if len(ret) == 0 {
1082- panic("no return value specified for GetConfig")
1083- }
1084-
1085- var r0 *remote.Config
1086- var r1 error
1087- if returnFunc, ok := ret.Get(0).(func(context.Context) (*remote.Config, error)); ok {
1088- return returnFunc(ctx)
1089- }
1090- if returnFunc, ok := ret.Get(0).(func(context.Context) *remote.Config); ok {
1091- r0 = returnFunc(ctx)
313+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) *dto.AppDeployResponse); ok {
314+ r0 = returnFunc(ctx, app, req)
1092315 } else {
1093316 if ret.Get(0) != nil {
1094- r0 = ret.Get(0).(*remote.Config)
317+ r0 = ret.Get(0).(*dto.AppDeployResponse)
1095318 }
1096319 }
1097- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1098- r1 = returnFunc(ctx)
320+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, dto.AppDeployRequest) string); ok {
321+ r1 = returnFunc(ctx, app, req)
1099322 } else {
1100- r1 = ret.Error(1)
323+ r1 = ret.Get(1).(string)
1101324 }
1102- return r0, r1
325+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, dto.AppDeployRequest) error); ok {
326+ r2 = returnFunc(ctx, app, req)
327+ } else {
328+ r2 = ret.Error(2)
329+ }
330+ return r0, r1, r2
1103331 }
1104332
1105-// MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig'
1106-type MockControlPlane_GetConfig_Call struct {
333+// MockControlPlane_DeployApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployApp'
334+type MockControlPlane_DeployApp_Call struct {
1107335 *mock.Call
1108336 }
1109337
1110-// GetConfig is a helper method to define mock.On call
338+// DeployApp is a helper method to define mock.On call
1111339 // - ctx context.Context
1112-func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call {
1113- return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}
340+// - app string
341+// - req dto.AppDeployRequest
342+func (_e *MockControlPlane_Expecter) DeployApp(ctx any, app any, req any) *MockControlPlane_DeployApp_Call {
343+ return &MockControlPlane_DeployApp_Call{Call: _e.mock.On("DeployApp", ctx, app, req)}
1114344 }
1115345
1116-func (_c *MockControlPlane_GetConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetConfig_Call {
346+func (_c *MockControlPlane_DeployApp_Call) Run(run func(ctx context.Context, app string, req dto.AppDeployRequest)) *MockControlPlane_DeployApp_Call {
1117347 _c.Call.Run(func(args mock.Arguments) {
1118348 var arg0 context.Context
1119349 if args[0] != nil {
1120350 arg0 = args[0].(context.Context)
1121351 }
352+ var arg1 string
353+ if args[1] != nil {
354+ arg1 = args[1].(string)
355+ }
356+ var arg2 dto.AppDeployRequest
357+ if args[2] != nil {
358+ arg2 = args[2].(dto.AppDeployRequest)
359+ }
1122360 run(
1123361 arg0,
362+ arg1,
363+ arg2,
1124364 )
1125365 })
1126366 return _c
1127367 }
1128368
1129-func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call {
1130- _c.Call.Return(config, err)
369+func (_c *MockControlPlane_DeployApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_DeployApp_Call {
370+ _c.Call.Return(appDeployResponse, s, err)
1131371 return _c
1132372 }
1133373
1134-func (_c *MockControlPlane_GetConfig_Call) RunAndReturn(run func(ctx context.Context) (*remote.Config, error)) *MockControlPlane_GetConfig_Call {
374+func (_c *MockControlPlane_DeployApp_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)) *MockControlPlane_DeployApp_Call {
1135375 _c.Call.Return(run)
1136376 return _c
1137377 }
1138378
1139-// GetContainerLogs provides a mock function for the type MockControlPlane
1140-func (_mock *MockControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
1141- ret := _mock.Called(ctx, logDomain, lines)
379+// DiffApp provides a mock function for the type MockControlPlane
380+func (_mock *MockControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
381+ ret := _mock.Called(ctx, app)
1142382
1143383 if len(ret) == 0 {
1144- panic("no return value specified for GetContainerLogs")
384+ panic("no return value specified for DiffApp")
1145385 }
1146386
1147- var r0 []string
387+ var r0 *dto.AppDiffResponse
1148388 var r1 error
1149- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]string, error)); ok {
1150- return returnFunc(ctx, logDomain, lines)
389+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDiffResponse, error)); ok {
390+ return returnFunc(ctx, app)
1151391 }
1152- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []string); ok {
1153- r0 = returnFunc(ctx, logDomain, lines)
392+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDiffResponse); ok {
393+ r0 = returnFunc(ctx, app)
1154394 } else {
1155395 if ret.Get(0) != nil {
1156- r0 = ret.Get(0).([]string)
396+ r0 = ret.Get(0).(*dto.AppDiffResponse)
1157397 }
1158398 }
1159- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
1160- r1 = returnFunc(ctx, logDomain, lines)
399+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
400+ r1 = returnFunc(ctx, app)
1161401 } else {
1162402 r1 = ret.Error(1)
1163403 }
1164404 return r0, r1
1165405 }
1166406
1167-// MockControlPlane_GetContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerLogs'
1168-type MockControlPlane_GetContainerLogs_Call struct {
407+// MockControlPlane_DiffApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiffApp'
408+type MockControlPlane_DiffApp_Call struct {
1169409 *mock.Call
1170410 }
1171411
1172-// GetContainerLogs is a helper method to define mock.On call
412+// DiffApp is a helper method to define mock.On call
1173413 // - ctx context.Context
1174-// - logDomain string
1175-// - lines int
1176-func (_e *MockControlPlane_Expecter) GetContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_GetContainerLogs_Call {
1177- return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)}
414+// - app string
415+func (_e *MockControlPlane_Expecter) DiffApp(ctx any, app any) *MockControlPlane_DiffApp_Call {
416+ return &MockControlPlane_DiffApp_Call{Call: _e.mock.On("DiffApp", ctx, app)}
1178417 }
1179418
1180-func (_c *MockControlPlane_GetContainerLogs_Call) Run(run func(ctx context.Context, logDomain string, lines int)) *MockControlPlane_GetContainerLogs_Call {
419+func (_c *MockControlPlane_DiffApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_DiffApp_Call {
1181420 _c.Call.Run(func(args mock.Arguments) {
1182421 var arg0 context.Context
1183422 if args[0] != nil {
@@ -1187,47 +426,42 @@ func (_c *MockControlPlane_GetContainerLogs_Call) Run(run func(ctx context.Conte
1187426 if args[1] != nil {
1188427 arg1 = args[1].(string)
1189428 }
1190- var arg2 int
1191- if args[2] != nil {
1192- arg2 = args[2].(int)
1193- }
1194429 run(
1195430 arg0,
1196431 arg1,
1197- arg2,
1198432 )
1199433 })
1200434 return _c
1201435 }
1202436
1203-func (_c *MockControlPlane_GetContainerLogs_Call) Return(strings []string, err error) *MockControlPlane_GetContainerLogs_Call {
1204- _c.Call.Return(strings, err)
437+func (_c *MockControlPlane_DiffApp_Call) Return(appDiffResponse *dto.AppDiffResponse, err error) *MockControlPlane_DiffApp_Call {
438+ _c.Call.Return(appDiffResponse, err)
1205439 return _c
1206440 }
1207441
1208-func (_c *MockControlPlane_GetContainerLogs_Call) RunAndReturn(run func(ctx context.Context, logDomain string, lines int) ([]string, error)) *MockControlPlane_GetContainerLogs_Call {
442+func (_c *MockControlPlane_DiffApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDiffResponse, error)) *MockControlPlane_DiffApp_Call {
1209443 _c.Call.Return(run)
1210444 return _c
1211445 }
1212446
1213-// GetHealth provides a mock function for the type MockControlPlane
1214-func (_mock *MockControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
447+// GetConfig provides a mock function for the type MockControlPlane
448+func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
1215449 ret := _mock.Called(ctx)
1216450
1217451 if len(ret) == 0 {
1218- panic("no return value specified for GetHealth")
452+ panic("no return value specified for GetConfig")
1219453 }
1220454
1221- var r0 map[string]*remote.RouteHealth
455+ var r0 *remote.Config
1222456 var r1 error
1223- if returnFunc, ok := ret.Get(0).(func(context.Context) (map[string]*remote.RouteHealth, error)); ok {
457+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*remote.Config, error)); ok {
1224458 return returnFunc(ctx)
1225459 }
1226- if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*remote.RouteHealth); ok {
460+ if returnFunc, ok := ret.Get(0).(func(context.Context) *remote.Config); ok {
1227461 r0 = returnFunc(ctx)
1228462 } else {
1229463 if ret.Get(0) != nil {
1230- r0 = ret.Get(0).(map[string]*remote.RouteHealth)
464+ r0 = ret.Get(0).(*remote.Config)
1231465 }
1232466 }
1233467 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -1238,18 +472,18 @@ func (_mock *MockControlPlane) GetHealth(ctx context.Context) (map[string]*remot
1238472 return r0, r1
1239473 }
1240474
1241-// MockControlPlane_GetHealth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHealth'
1242-type MockControlPlane_GetHealth_Call struct {
475+// MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig'
476+type MockControlPlane_GetConfig_Call struct {
1243477 *mock.Call
1244478 }
1245479
1246-// GetHealth is a helper method to define mock.On call
480+// GetConfig is a helper method to define mock.On call
1247481 // - ctx context.Context
1248-func (_e *MockControlPlane_Expecter) GetHealth(ctx any) *MockControlPlane_GetHealth_Call {
1249- return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)}
482+func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call {
483+ return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}
1250484 }
1251485
1252-func (_c *MockControlPlane_GetHealth_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetHealth_Call {
486+func (_c *MockControlPlane_GetConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetConfig_Call {
1253487 _c.Call.Run(func(args mock.Arguments) {
1254488 var arg0 context.Context
1255489 if args[0] != nil {
@@ -1262,12 +496,12 @@ func (_c *MockControlPlane_GetHealth_Call) Run(run func(ctx context.Context)) *M
1262496 return _c
1263497 }
1264498
1265-func (_c *MockControlPlane_GetHealth_Call) Return(stringToRouteHealth map[string]*remote.RouteHealth, err error) *MockControlPlane_GetHealth_Call {
1266- _c.Call.Return(stringToRouteHealth, err)
499+func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call {
500+ _c.Call.Return(config, err)
1267501 return _c
1268502 }
1269503
1270-func (_c *MockControlPlane_GetHealth_Call) RunAndReturn(run func(ctx context.Context) (map[string]*remote.RouteHealth, error)) *MockControlPlane_GetHealth_Call {
504+func (_c *MockControlPlane_GetConfig_Call) RunAndReturn(run func(ctx context.Context) (*remote.Config, error)) *MockControlPlane_GetConfig_Call {
1271505 _c.Call.Return(run)
1272506 return _c
1273507 }
@@ -1340,74 +574,6 @@ func (_c *MockControlPlane_GetProcessLogs_Call) RunAndReturn(run func(ctx contex
1340574 return _c
1341575 }
1342576
1343-// GetRoute provides a mock function for the type MockControlPlane
1344-func (_mock *MockControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
1345- ret := _mock.Called(ctx, routeDomain)
1346-
1347- if len(ret) == 0 {
1348- panic("no return value specified for GetRoute")
1349- }
1350-
1351- var r0 *domain.Route
1352- var r1 error
1353- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.Route, error)); ok {
1354- return returnFunc(ctx, routeDomain)
1355- }
1356- if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.Route); ok {
1357- r0 = returnFunc(ctx, routeDomain)
1358- } else {
1359- if ret.Get(0) != nil {
1360- r0 = ret.Get(0).(*domain.Route)
1361- }
1362- }
1363- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1364- r1 = returnFunc(ctx, routeDomain)
1365- } else {
1366- r1 = ret.Error(1)
1367- }
1368- return r0, r1
1369-}
1370-
1371-// MockControlPlane_GetRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoute'
1372-type MockControlPlane_GetRoute_Call struct {
1373- *mock.Call
1374-}
1375-
1376-// GetRoute is a helper method to define mock.On call
1377-// - ctx context.Context
1378-// - routeDomain string
1379-func (_e *MockControlPlane_Expecter) GetRoute(ctx any, routeDomain any) *MockControlPlane_GetRoute_Call {
1380- return &MockControlPlane_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, routeDomain)}
1381-}
1382-
1383-func (_c *MockControlPlane_GetRoute_Call) Run(run func(ctx context.Context, routeDomain string)) *MockControlPlane_GetRoute_Call {
1384- _c.Call.Run(func(args mock.Arguments) {
1385- var arg0 context.Context
1386- if args[0] != nil {
1387- arg0 = args[0].(context.Context)
1388- }
1389- var arg1 string
1390- if args[1] != nil {
1391- arg1 = args[1].(string)
1392- }
1393- run(
1394- arg0,
1395- arg1,
1396- )
1397- })
1398- return _c
1399-}
1400-
1401-func (_c *MockControlPlane_GetRoute_Call) Return(route *domain.Route, err error) *MockControlPlane_GetRoute_Call {
1402- _c.Call.Return(route, err)
1403- return _c
1404-}
1405-
1406-func (_c *MockControlPlane_GetRoute_Call) RunAndReturn(run func(ctx context.Context, routeDomain string) (*domain.Route, error)) *MockControlPlane_GetRoute_Call {
1407- _c.Call.Return(run)
1408- return _c
1409-}
1410-
1411577 // GetStatus provides a mock function for the type MockControlPlane
1412578 func (_mock *MockControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
1413579 ret := _mock.Called(ctx)
@@ -1594,92 +760,24 @@ func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx cont
1594760 return _c
1595761 }
1596762
1597-// ListBackups provides a mock function for the type MockControlPlane
1598-func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
1599- ret := _mock.Called(ctx, backupDomain)
1600-
1601- if len(ret) == 0 {
1602- panic("no return value specified for ListBackups")
1603- }
1604-
1605- var r0 []dto.BackupJob
1606- var r1 error
1607- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.BackupJob, error)); ok {
1608- return returnFunc(ctx, backupDomain)
1609- }
1610- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.BackupJob); ok {
1611- r0 = returnFunc(ctx, backupDomain)
1612- } else {
1613- if ret.Get(0) != nil {
1614- r0 = ret.Get(0).([]dto.BackupJob)
1615- }
1616- }
1617- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1618- r1 = returnFunc(ctx, backupDomain)
1619- } else {
1620- r1 = ret.Error(1)
1621- }
1622- return r0, r1
1623-}
1624-
1625-// MockControlPlane_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups'
1626-type MockControlPlane_ListBackups_Call struct {
1627- *mock.Call
1628-}
1629-
1630-// ListBackups is a helper method to define mock.On call
1631-// - ctx context.Context
1632-// - backupDomain string
1633-func (_e *MockControlPlane_Expecter) ListBackups(ctx any, backupDomain any) *MockControlPlane_ListBackups_Call {
1634- return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, backupDomain)}
1635-}
1636-
1637-func (_c *MockControlPlane_ListBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListBackups_Call {
1638- _c.Call.Run(func(args mock.Arguments) {
1639- var arg0 context.Context
1640- if args[0] != nil {
1641- arg0 = args[0].(context.Context)
1642- }
1643- var arg1 string
1644- if args[1] != nil {
1645- arg1 = args[1].(string)
1646- }
1647- run(
1648- arg0,
1649- arg1,
1650- )
1651- })
1652- return _c
1653-}
1654-
1655-func (_c *MockControlPlane_ListBackups_Call) Return(backupJobs []dto.BackupJob, err error) *MockControlPlane_ListBackups_Call {
1656- _c.Call.Return(backupJobs, err)
1657- return _c
1658-}
1659-
1660-func (_c *MockControlPlane_ListBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)) *MockControlPlane_ListBackups_Call {
1661- _c.Call.Return(run)
1662- return _c
1663-}
1664-
1665-// ListNetworks provides a mock function for the type MockControlPlane
1666-func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
763+// ListApps provides a mock function for the type MockControlPlane
764+func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
1667765 ret := _mock.Called(ctx)
1668766
1669767 if len(ret) == 0 {
1670- panic("no return value specified for ListNetworks")
768+ panic("no return value specified for ListApps")
1671769 }
1672770
1673- var r0 []*domain.NetworkInfo
771+ var r0 []dto.AppSummaryDTO
1674772 var r1 error
1675- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok {
773+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]dto.AppSummaryDTO, error)); ok {
1676774 return returnFunc(ctx)
1677775 }
1678- if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok {
776+ if returnFunc, ok := ret.Get(0).(func(context.Context) []dto.AppSummaryDTO); ok {
1679777 r0 = returnFunc(ctx)
1680778 } else {
1681779 if ret.Get(0) != nil {
1682- r0 = ret.Get(0).([]*domain.NetworkInfo)
780+ r0 = ret.Get(0).([]dto.AppSummaryDTO)
1683781 }
1684782 }
1685783 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -1690,18 +788,18 @@ func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.Netw
1690788 return r0, r1
1691789 }
1692790
1693-// MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'
1694-type MockControlPlane_ListNetworks_Call struct {
791+// MockControlPlane_ListApps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListApps'
792+type MockControlPlane_ListApps_Call struct {
1695793 *mock.Call
1696794 }
1697795
1698-// ListNetworks is a helper method to define mock.On call
796+// ListApps is a helper method to define mock.On call
1699797 // - ctx context.Context
1700-func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call {
1701- return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
798+func (_e *MockControlPlane_Expecter) ListApps(ctx any) *MockControlPlane_ListApps_Call {
799+ return &MockControlPlane_ListApps_Call{Call: _e.mock.On("ListApps", ctx)}
1702800 }
1703801
1704-func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListNetworks_Call {
802+func (_c *MockControlPlane_ListApps_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListApps_Call {
1705803 _c.Call.Run(func(args mock.Arguments) {
1706804 var arg0 context.Context
1707805 if args[0] != nil {
@@ -1714,96 +812,102 @@ func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context))
1714812 return _c
1715813 }
1716814
1717-func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call {
1718- _c.Call.Return(networkInfos, err)
815+func (_c *MockControlPlane_ListApps_Call) Return(appSummaryDTOs []dto.AppSummaryDTO, err error) *MockControlPlane_ListApps_Call {
816+ _c.Call.Return(appSummaryDTOs, err)
1719817 return _c
1720818 }
1721819
1722-func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockControlPlane_ListNetworks_Call {
820+func (_c *MockControlPlane_ListApps_Call) RunAndReturn(run func(ctx context.Context) ([]dto.AppSummaryDTO, error)) *MockControlPlane_ListApps_Call {
1723821 _c.Call.Return(run)
1724822 return _c
1725823 }
1726824
1727-// ListOrphanedAttachments provides a mock function for the type MockControlPlane
1728-func (_mock *MockControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1729- ret := _mock.Called(ctx)
825+// ListBackups provides a mock function for the type MockControlPlane
826+func (_mock *MockControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
827+ ret := _mock.Called(ctx, app)
1730828
1731829 if len(ret) == 0 {
1732- panic("no return value specified for ListOrphanedAttachments")
830+ panic("no return value specified for ListBackups")
1733831 }
1734832
1735- var r0 []domain.CleanupAttachment
833+ var r0 []dto.BackupJob
1736834 var r1 error
1737- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CleanupAttachment, error)); ok {
1738- return returnFunc(ctx)
835+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.BackupJob, error)); ok {
836+ return returnFunc(ctx, app)
1739837 }
1740- if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CleanupAttachment); ok {
1741- r0 = returnFunc(ctx)
838+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.BackupJob); ok {
839+ r0 = returnFunc(ctx, app)
1742840 } else {
1743841 if ret.Get(0) != nil {
1744- r0 = ret.Get(0).([]domain.CleanupAttachment)
842+ r0 = ret.Get(0).([]dto.BackupJob)
1745843 }
1746844 }
1747- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1748- r1 = returnFunc(ctx)
845+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
846+ r1 = returnFunc(ctx, app)
1749847 } else {
1750848 r1 = ret.Error(1)
1751849 }
1752850 return r0, r1
1753851 }
1754852
1755-// MockControlPlane_ListOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrphanedAttachments'
1756-type MockControlPlane_ListOrphanedAttachments_Call struct {
853+// MockControlPlane_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups'
854+type MockControlPlane_ListBackups_Call struct {
1757855 *mock.Call
1758856 }
1759857
1760-// ListOrphanedAttachments is a helper method to define mock.On call
858+// ListBackups is a helper method to define mock.On call
1761859 // - ctx context.Context
1762-func (_e *MockControlPlane_Expecter) ListOrphanedAttachments(ctx any) *MockControlPlane_ListOrphanedAttachments_Call {
1763- return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}
860+// - app string
861+func (_e *MockControlPlane_Expecter) ListBackups(ctx any, app any) *MockControlPlane_ListBackups_Call {
862+ return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, app)}
1764863 }
1765864
1766-func (_c *MockControlPlane_ListOrphanedAttachments_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListOrphanedAttachments_Call {
865+func (_c *MockControlPlane_ListBackups_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ListBackups_Call {
1767866 _c.Call.Run(func(args mock.Arguments) {
1768867 var arg0 context.Context
1769868 if args[0] != nil {
1770869 arg0 = args[0].(context.Context)
1771870 }
871+ var arg1 string
872+ if args[1] != nil {
873+ arg1 = args[1].(string)
874+ }
1772875 run(
1773876 arg0,
877+ arg1,
1774878 )
1775879 })
1776880 return _c
1777881 }
1778882
1779-func (_c *MockControlPlane_ListOrphanedAttachments_Call) Return(cleanupAttachments []domain.CleanupAttachment, err error) *MockControlPlane_ListOrphanedAttachments_Call {
1780- _c.Call.Return(cleanupAttachments, err)
883+func (_c *MockControlPlane_ListBackups_Call) Return(backupJobs []dto.BackupJob, err error) *MockControlPlane_ListBackups_Call {
884+ _c.Call.Return(backupJobs, err)
1781885 return _c
1782886 }
1783887
1784-func (_c *MockControlPlane_ListOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CleanupAttachment, error)) *MockControlPlane_ListOrphanedAttachments_Call {
888+func (_c *MockControlPlane_ListBackups_Call) RunAndReturn(run func(ctx context.Context, app string) ([]dto.BackupJob, error)) *MockControlPlane_ListBackups_Call {
1785889 _c.Call.Return(run)
1786890 return _c
1787891 }
1788892
1789-// ListRoutesWithDetails provides a mock function for the type MockControlPlane
1790-func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
893+// ListNetworks provides a mock function for the type MockControlPlane
894+func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1791895 ret := _mock.Called(ctx)
1792896
1793897 if len(ret) == 0 {
1794- panic("no return value specified for ListRoutesWithDetails")
898+ panic("no return value specified for ListNetworks")
1795899 }
1796900
1797- var r0 []remote.RouteInfo
901+ var r0 []*domain.NetworkInfo
1798902 var r1 error
1799- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]remote.RouteInfo, error)); ok {
903+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok {
1800904 return returnFunc(ctx)
1801905 }
1802- if returnFunc, ok := ret.Get(0).(func(context.Context) []remote.RouteInfo); ok {
906+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok {
1803907 r0 = returnFunc(ctx)
1804908 } else {
1805909 if ret.Get(0) != nil {
1806- r0 = ret.Get(0).([]remote.RouteInfo)
910+ r0 = ret.Get(0).([]*domain.NetworkInfo)
1807911 }
1808912 }
1809913 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -1814,18 +918,18 @@ func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]rem
1814918 return r0, r1
1815919 }
1816920
1817-// MockControlPlane_ListRoutesWithDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRoutesWithDetails'
1818-type MockControlPlane_ListRoutesWithDetails_Call struct {
921+// MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'
922+type MockControlPlane_ListNetworks_Call struct {
1819923 *mock.Call
1820924 }
1821925
1822-// ListRoutesWithDetails is a helper method to define mock.On call
926+// ListNetworks is a helper method to define mock.On call
1823927 // - ctx context.Context
1824-func (_e *MockControlPlane_Expecter) ListRoutesWithDetails(ctx any) *MockControlPlane_ListRoutesWithDetails_Call {
1825- return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
928+func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call {
929+ return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
1826930 }
1827931
1828-func (_c *MockControlPlane_ListRoutesWithDetails_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListRoutesWithDetails_Call {
932+func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListNetworks_Call {
1829933 _c.Call.Run(func(args mock.Arguments) {
1830934 var arg0 context.Context
1831935 if args[0] != nil {
@@ -1838,22 +942,22 @@ func (_c *MockControlPlane_ListRoutesWithDetails_Call) Run(run func(ctx context.
1838942 return _c
1839943 }
1840944
1841-func (_c *MockControlPlane_ListRoutesWithDetails_Call) Return(vs []remote.RouteInfo, err error) *MockControlPlane_ListRoutesWithDetails_Call {
1842- _c.Call.Return(vs, err)
945+func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call {
946+ _c.Call.Return(networkInfos, err)
1843947 return _c
1844948 }
1845949
1846-func (_c *MockControlPlane_ListRoutesWithDetails_Call) RunAndReturn(run func(ctx context.Context) ([]remote.RouteInfo, error)) *MockControlPlane_ListRoutesWithDetails_Call {
950+func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockControlPlane_ListNetworks_Call {
1847951 _c.Call.Return(run)
1848952 return _c
1849953 }
1850954
1851-// ListSecretsWithAttachments provides a mock function for the type MockControlPlane
1852-func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
955+// ListSecrets provides a mock function for the type MockControlPlane
956+func (_mock *MockControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
1853957 ret := _mock.Called(ctx, secretDomain)
1854958
1855959 if len(ret) == 0 {
1856- panic("no return value specified for ListSecretsWithAttachments")
960+ panic("no return value specified for ListSecrets")
1857961 }
1858962
1859963 var r0 *remote.SecretsListResult
@@ -1876,19 +980,19 @@ func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, s
1876980 return r0, r1
1877981 }
1878982
1879-// MockControlPlane_ListSecretsWithAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecretsWithAttachments'
1880-type MockControlPlane_ListSecretsWithAttachments_Call struct {
983+// MockControlPlane_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets'
984+type MockControlPlane_ListSecrets_Call struct {
1881985 *mock.Call
1882986 }
1883987
1884-// ListSecretsWithAttachments is a helper method to define mock.On call
988+// ListSecrets is a helper method to define mock.On call
1885989 // - ctx context.Context
1886990 // - secretDomain string
1887-func (_e *MockControlPlane_Expecter) ListSecretsWithAttachments(ctx any, secretDomain any) *MockControlPlane_ListSecretsWithAttachments_Call {
1888- return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)}
991+func (_e *MockControlPlane_Expecter) ListSecrets(ctx any, secretDomain any) *MockControlPlane_ListSecrets_Call {
992+ return &MockControlPlane_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, secretDomain)}
1889993 }
1890994
1891-func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecretsWithAttachments_Call {
995+func (_c *MockControlPlane_ListSecrets_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecrets_Call {
1892996 _c.Call.Run(func(args mock.Arguments) {
1893997 var arg0 context.Context
1894998 if args[0] != nil {
@@ -1906,12 +1010,12 @@ func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Run(run func(ctx con
19061010 return _c
19071011 }
19081012
1909-func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecretsWithAttachments_Call {
1013+func (_c *MockControlPlane_ListSecrets_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecrets_Call {
19101014 _c.Call.Return(secretsListResult, err)
19111015 return _c
19121016 }
19131017
1914-func (_c *MockControlPlane_ListSecretsWithAttachments_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecretsWithAttachments_Call {
1018+func (_c *MockControlPlane_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecrets_Call {
19151019 _c.Call.Return(run)
19161020 return _c
19171021 }
@@ -1985,8 +1089,8 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont
19851089 }
19861090
19871091 // ListVolumeBackups provides a mock function for the type MockControlPlane
1988-func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
1989- ret := _mock.Called(ctx, backupDomain)
1092+func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
1093+ ret := _mock.Called(ctx, app)
19901094
19911095 if len(ret) == 0 {
19921096 panic("no return value specified for ListVolumeBackups")
@@ -1995,17 +1099,17 @@ func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDoma
19951099 var r0 []dto.VolumeBackupJob
19961100 var r1 error
19971101 if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok {
1998- return returnFunc(ctx, backupDomain)
1102+ return returnFunc(ctx, app)
19991103 }
20001104 if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok {
2001- r0 = returnFunc(ctx, backupDomain)
1105+ r0 = returnFunc(ctx, app)
20021106 } else {
20031107 if ret.Get(0) != nil {
20041108 r0 = ret.Get(0).([]dto.VolumeBackupJob)
20051109 }
20061110 }
20071111 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
2008- r1 = returnFunc(ctx, backupDomain)
1112+ r1 = returnFunc(ctx, app)
20091113 } else {
20101114 r1 = ret.Error(1)
20111115 }
@@ -2019,12 +1123,12 @@ type MockControlPlane_ListVolumeBackups_Call struct {
20191123
20201124 // ListVolumeBackups is a helper method to define mock.On call
20211125 // - ctx context.Context
2022-// - backupDomain string
2023-func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, backupDomain any) *MockControlPlane_ListVolumeBackups_Call {
2024- return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}
1126+// - app string
1127+func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, app any) *MockControlPlane_ListVolumeBackups_Call {
1128+ return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, app)}
20251129 }
20261130
2027-func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListVolumeBackups_Call {
1131+func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ListVolumeBackups_Call {
20281132 _c.Call.Run(func(args mock.Arguments) {
20291133 var arg0 context.Context
20301134 if args[0] != nil {
@@ -2047,7 +1151,7 @@ func (_c *MockControlPlane_ListVolumeBackups_Call) Return(volumeBackupJobs []dto
20471151 return _c
20481152 }
20491153
2050-func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call {
1154+func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call {
20511155 _c.Call.Return(run)
20521156 return _c
20531157 }
@@ -2085,31 +1189,105 @@ type MockControlPlane_ListVolumes_Call struct {
20851189 *mock.Call
20861190 }
20871191
2088-// ListVolumes is a helper method to define mock.On call
1192+// ListVolumes is a helper method to define mock.On call
1193+// - ctx context.Context
1194+func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call {
1195+ return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
1196+}
1197+
1198+func (_c *MockControlPlane_ListVolumes_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListVolumes_Call {
1199+ _c.Call.Run(func(args mock.Arguments) {
1200+ var arg0 context.Context
1201+ if args[0] != nil {
1202+ arg0 = args[0].(context.Context)
1203+ }
1204+ run(
1205+ arg0,
1206+ )
1207+ })
1208+ return _c
1209+}
1210+
1211+func (_c *MockControlPlane_ListVolumes_Call) Return(volumes []dto.Volume, err error) *MockControlPlane_ListVolumes_Call {
1212+ _c.Call.Return(volumes, err)
1213+ return _c
1214+}
1215+
1216+func (_c *MockControlPlane_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]dto.Volume, error)) *MockControlPlane_ListVolumes_Call {
1217+ _c.Call.Return(run)
1218+ return _c
1219+}
1220+
1221+// OperationByKey provides a mock function for the type MockControlPlane
1222+func (_mock *MockControlPlane) OperationByKey(ctx context.Context, app string, key string) (*dto.AppDeployResponse, error) {
1223+ ret := _mock.Called(ctx, app, key)
1224+
1225+ if len(ret) == 0 {
1226+ panic("no return value specified for OperationByKey")
1227+ }
1228+
1229+ var r0 *dto.AppDeployResponse
1230+ var r1 error
1231+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, error)); ok {
1232+ return returnFunc(ctx, app, key)
1233+ }
1234+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok {
1235+ r0 = returnFunc(ctx, app, key)
1236+ } else {
1237+ if ret.Get(0) != nil {
1238+ r0 = ret.Get(0).(*dto.AppDeployResponse)
1239+ }
1240+ }
1241+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
1242+ r1 = returnFunc(ctx, app, key)
1243+ } else {
1244+ r1 = ret.Error(1)
1245+ }
1246+ return r0, r1
1247+}
1248+
1249+// MockControlPlane_OperationByKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OperationByKey'
1250+type MockControlPlane_OperationByKey_Call struct {
1251+ *mock.Call
1252+}
1253+
1254+// OperationByKey is a helper method to define mock.On call
20891255 // - ctx context.Context
2090-func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call {
2091- return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
1256+// - app string
1257+// - key string
1258+func (_e *MockControlPlane_Expecter) OperationByKey(ctx any, app any, key any) *MockControlPlane_OperationByKey_Call {
1259+ return &MockControlPlane_OperationByKey_Call{Call: _e.mock.On("OperationByKey", ctx, app, key)}
20921260 }
20931261
2094-func (_c *MockControlPlane_ListVolumes_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListVolumes_Call {
1262+func (_c *MockControlPlane_OperationByKey_Call) Run(run func(ctx context.Context, app string, key string)) *MockControlPlane_OperationByKey_Call {
20951263 _c.Call.Run(func(args mock.Arguments) {
20961264 var arg0 context.Context
20971265 if args[0] != nil {
20981266 arg0 = args[0].(context.Context)
20991267 }
1268+ var arg1 string
1269+ if args[1] != nil {
1270+ arg1 = args[1].(string)
1271+ }
1272+ var arg2 string
1273+ if args[2] != nil {
1274+ arg2 = args[2].(string)
1275+ }
21001276 run(
21011277 arg0,
1278+ arg1,
1279+ arg2,
21021280 )
21031281 })
21041282 return _c
21051283 }
21061284
2107-func (_c *MockControlPlane_ListVolumes_Call) Return(volumes []dto.Volume, err error) *MockControlPlane_ListVolumes_Call {
2108- _c.Call.Return(volumes, err)
1285+func (_c *MockControlPlane_OperationByKey_Call) Return(appDeployResponse *dto.AppDeployResponse, err error) *MockControlPlane_OperationByKey_Call {
1286+ _c.Call.Return(appDeployResponse, err)
21091287 return _c
21101288 }
21111289
2112-func (_c *MockControlPlane_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]dto.Volume, error)) *MockControlPlane_ListVolumes_Call {
1290+func (_c *MockControlPlane_OperationByKey_Call) RunAndReturn(run func(ctx context.Context, app string, key string) (*dto.AppDeployResponse, error)) *MockControlPlane_OperationByKey_Call {
21131291 _c.Call.Return(run)
21141292 return _c
21151293 }
@@ -2233,156 +1411,53 @@ func (_c *MockControlPlane_Reload_Call) RunAndReturn(run func(ctx context.Contex
22331411 return _c
22341412 }
22351413
2236-// RemoveAttachment provides a mock function for the type MockControlPlane
2237-func (_mock *MockControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
2238- ret := _mock.Called(ctx, domainOrGroup, image)
1414+// RemoveApp provides a mock function for the type MockControlPlane
1415+func (_mock *MockControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
1416+ ret := _mock.Called(ctx, app)
22391417
22401418 if len(ret) == 0 {
2241- panic("no return value specified for RemoveAttachment")
1419+ panic("no return value specified for RemoveApp")
22421420 }
22431421
2244- var r0 error
2245- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
2246- r0 = returnFunc(ctx, domainOrGroup, image)
2247- } else {
2248- r0 = ret.Error(0)
1422+ var r0 *dto.AppDeployResponse
1423+ var r1 string
1424+ var r2 error
1425+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok {
1426+ return returnFunc(ctx, app)
22491427 }
2250- return r0
2251-}
2252-
2253-// MockControlPlane_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment'
2254-type MockControlPlane_RemoveAttachment_Call struct {
2255- *mock.Call
2256-}
2257-
2258-// RemoveAttachment is a helper method to define mock.On call
2259-// - ctx context.Context
2260-// - domainOrGroup string
2261-// - image string
2262-func (_e *MockControlPlane_Expecter) RemoveAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_RemoveAttachment_Call {
2263- return &MockControlPlane_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
2264-}
2265-
2266-func (_c *MockControlPlane_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockControlPlane_RemoveAttachment_Call {
2267- _c.Call.Run(func(args mock.Arguments) {
2268- var arg0 context.Context
2269- if args[0] != nil {
2270- arg0 = args[0].(context.Context)
2271- }
2272- var arg1 string
2273- if args[1] != nil {
2274- arg1 = args[1].(string)
2275- }
2276- var arg2 string
2277- if args[2] != nil {
2278- arg2 = args[2].(string)
1428+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok {
1429+ r0 = returnFunc(ctx, app)
1430+ } else {
1431+ if ret.Get(0) != nil {
1432+ r0 = ret.Get(0).(*dto.AppDeployResponse)
22791433 }
2280- run(
2281- arg0,
2282- arg1,
2283- arg2,
2284- )
2285- })
2286- return _c
2287-}
2288-
2289-func (_c *MockControlPlane_RemoveAttachment_Call) Return(err error) *MockControlPlane_RemoveAttachment_Call {
2290- _c.Call.Return(err)
2291- return _c
2292-}
2293-
2294-func (_c *MockControlPlane_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockControlPlane_RemoveAttachment_Call {
2295- _c.Call.Return(run)
2296- return _c
2297-}
2298-
2299-// RemoveAutoRouteAllowedDomain provides a mock function for the type MockControlPlane
2300-func (_mock *MockControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
2301- ret := _mock.Called(ctx, pattern)
2302-
2303- if len(ret) == 0 {
2304- panic("no return value specified for RemoveAutoRouteAllowedDomain")
23051434 }
2306-
2307- var r0 error
2308- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
2309- r0 = returnFunc(ctx, pattern)
1435+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok {
1436+ r1 = returnFunc(ctx, app)
23101437 } else {
2311- r0 = ret.Error(0)
2312- }
2313- return r0
2314-}
2315-
2316-// MockControlPlane_RemoveAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAutoRouteAllowedDomain'
2317-type MockControlPlane_RemoveAutoRouteAllowedDomain_Call struct {
2318- *mock.Call
2319-}
2320-
2321-// RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
2322-// - ctx context.Context
2323-// - pattern string
2324-func (_e *MockControlPlane_Expecter) RemoveAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
2325- return &MockControlPlane_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
2326-}
2327-
2328-func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
2329- _c.Call.Run(func(args mock.Arguments) {
2330- var arg0 context.Context
2331- if args[0] != nil {
2332- arg0 = args[0].(context.Context)
2333- }
2334- var arg1 string
2335- if args[1] != nil {
2336- arg1 = args[1].(string)
2337- }
2338- run(
2339- arg0,
2340- arg1,
2341- )
2342- })
2343- return _c
2344-}
2345-
2346-func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) Return(err error) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
2347- _c.Call.Return(err)
2348- return _c
2349-}
2350-
2351-func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
2352- _c.Call.Return(run)
2353- return _c
2354-}
2355-
2356-// RemoveRoute provides a mock function for the type MockControlPlane
2357-func (_mock *MockControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
2358- ret := _mock.Called(ctx, routeDomain)
2359-
2360- if len(ret) == 0 {
2361- panic("no return value specified for RemoveRoute")
1438+ r1 = ret.Get(1).(string)
23621439 }
2363-
2364- var r0 error
2365- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
2366- r0 = returnFunc(ctx, routeDomain)
1440+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
1441+ r2 = returnFunc(ctx, app)
23671442 } else {
2368- r0 = ret.Error(0)
1443+ r2 = ret.Error(2)
23691444 }
2370- return r0
1445+ return r0, r1, r2
23711446 }
23721447
2373-// MockControlPlane_RemoveRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRoute'
2374-type MockControlPlane_RemoveRoute_Call struct {
1448+// MockControlPlane_RemoveApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveApp'
1449+type MockControlPlane_RemoveApp_Call struct {
23751450 *mock.Call
23761451 }
23771452
2378-// RemoveRoute is a helper method to define mock.On call
1453+// RemoveApp is a helper method to define mock.On call
23791454 // - ctx context.Context
2380-// - routeDomain string
2381-func (_e *MockControlPlane_Expecter) RemoveRoute(ctx any, routeDomain any) *MockControlPlane_RemoveRoute_Call {
2382- return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)}
1455+// - app string
1456+func (_e *MockControlPlane_Expecter) RemoveApp(ctx any, app any) *MockControlPlane_RemoveApp_Call {
1457+ return &MockControlPlane_RemoveApp_Call{Call: _e.mock.On("RemoveApp", ctx, app)}
23831458 }
23841459
2385-func (_c *MockControlPlane_RemoveRoute_Call) Run(run func(ctx context.Context, routeDomain string)) *MockControlPlane_RemoveRoute_Call {
1460+func (_c *MockControlPlane_RemoveApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_RemoveApp_Call {
23861461 _c.Call.Run(func(args mock.Arguments) {
23871462 var arg0 context.Context
23881463 if args[0] != nil {
@@ -2400,58 +1475,64 @@ func (_c *MockControlPlane_RemoveRoute_Call) Run(run func(ctx context.Context, r
24001475 return _c
24011476 }
24021477
2403-func (_c *MockControlPlane_RemoveRoute_Call) Return(err error) *MockControlPlane_RemoveRoute_Call {
2404- _c.Call.Return(err)
1478+func (_c *MockControlPlane_RemoveApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RemoveApp_Call {
1479+ _c.Call.Return(appDeployResponse, s, err)
24051480 return _c
24061481 }
24071482
2408-func (_c *MockControlPlane_RemoveRoute_Call) RunAndReturn(run func(ctx context.Context, routeDomain string) error) *MockControlPlane_RemoveRoute_Call {
1483+func (_c *MockControlPlane_RemoveApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RemoveApp_Call {
24091484 _c.Call.Return(run)
24101485 return _c
24111486 }
24121487
2413-// Restart provides a mock function for the type MockControlPlane
2414-func (_mock *MockControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
2415- ret := _mock.Called(ctx, restartDomain, withAttachments)
1488+// RestartApp provides a mock function for the type MockControlPlane
1489+func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error) {
1490+ ret := _mock.Called(ctx, app, service)
24161491
24171492 if len(ret) == 0 {
2418- panic("no return value specified for Restart")
1493+ panic("no return value specified for RestartApp")
24191494 }
24201495
2421- var r0 *remote.RestartResult
2422- var r1 error
2423- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*remote.RestartResult, error)); ok {
2424- return returnFunc(ctx, restartDomain, withAttachments)
1496+ var r0 *dto.AppDeployResponse
1497+ var r1 string
1498+ var r2 error
1499+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, string, error)); ok {
1500+ return returnFunc(ctx, app, service)
24251501 }
2426- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *remote.RestartResult); ok {
2427- r0 = returnFunc(ctx, restartDomain, withAttachments)
1502+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok {
1503+ r0 = returnFunc(ctx, app, service)
24281504 } else {
24291505 if ret.Get(0) != nil {
2430- r0 = ret.Get(0).(*remote.RestartResult)
1506+ r0 = ret.Get(0).(*dto.AppDeployResponse)
24311507 }
24321508 }
2433- if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
2434- r1 = returnFunc(ctx, restartDomain, withAttachments)
1509+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
1510+ r1 = returnFunc(ctx, app, service)
24351511 } else {
2436- r1 = ret.Error(1)
1512+ r1 = ret.Get(1).(string)
24371513 }
2438- return r0, r1
1514+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
1515+ r2 = returnFunc(ctx, app, service)
1516+ } else {
1517+ r2 = ret.Error(2)
1518+ }
1519+ return r0, r1, r2
24391520 }
24401521
2441-// MockControlPlane_Restart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restart'
2442-type MockControlPlane_Restart_Call struct {
1522+// MockControlPlane_RestartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartApp'
1523+type MockControlPlane_RestartApp_Call struct {
24431524 *mock.Call
24441525 }
24451526
2446-// Restart is a helper method to define mock.On call
1527+// RestartApp is a helper method to define mock.On call
24471528 // - ctx context.Context
2448-// - restartDomain string
2449-// - withAttachments bool
2450-func (_e *MockControlPlane_Expecter) Restart(ctx any, restartDomain any, withAttachments any) *MockControlPlane_Restart_Call {
2451- return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)}
1529+// - app string
1530+// - service string
1531+func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any) *MockControlPlane_RestartApp_Call {
1532+ return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service)}
24521533 }
24531534
2454-func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, restartDomain string, withAttachments bool)) *MockControlPlane_Restart_Call {
1535+func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string)) *MockControlPlane_RestartApp_Call {
24551536 _c.Call.Run(func(args mock.Arguments) {
24561537 var arg0 context.Context
24571538 if args[0] != nil {
@@ -2461,9 +1542,9 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta
24611542 if args[1] != nil {
24621543 arg1 = args[1].(string)
24631544 }
2464- var arg2 bool
1545+ var arg2 string
24651546 if args[2] != nil {
2466- arg2 = args[2].(bool)
1547+ arg2 = args[2].(string)
24671548 }
24681549 run(
24691550 arg0,
@@ -2474,19 +1555,19 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta
24741555 return _c
24751556 }
24761557
2477-func (_c *MockControlPlane_Restart_Call) Return(restartResult *remote.RestartResult, err error) *MockControlPlane_Restart_Call {
2478- _c.Call.Return(restartResult, err)
1558+func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RestartApp_Call {
1559+ _c.Call.Return(appDeployResponse, s, err)
24791560 return _c
24801561 }
24811562
2482-func (_c *MockControlPlane_Restart_Call) RunAndReturn(run func(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)) *MockControlPlane_Restart_Call {
1563+func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call {
24831564 _c.Call.Return(run)
24841565 return _c
24851566 }
24861567
24871568 // RunBackup provides a mock function for the type MockControlPlane
2488-func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain string, dbName string) (*dto.BackupRunResponse, error) {
2489- ret := _mock.Called(ctx, backupDomain, dbName)
1569+func (_mock *MockControlPlane) RunBackup(ctx context.Context, app string, service string, database string) (*dto.BackupRunResponse, error) {
1570+ ret := _mock.Called(ctx, app, service, database)
24901571
24911572 if len(ret) == 0 {
24921573 panic("no return value specified for RunBackup")
@@ -2494,18 +1575,18 @@ func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain strin
24941575
24951576 var r0 *dto.BackupRunResponse
24961577 var r1 error
2497- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.BackupRunResponse, error)); ok {
2498- return returnFunc(ctx, backupDomain, dbName)
1578+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.BackupRunResponse, error)); ok {
1579+ return returnFunc(ctx, app, service, database)
24991580 }
2500- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.BackupRunResponse); ok {
2501- r0 = returnFunc(ctx, backupDomain, dbName)
1581+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.BackupRunResponse); ok {
1582+ r0 = returnFunc(ctx, app, service, database)
25021583 } else {
25031584 if ret.Get(0) != nil {
25041585 r0 = ret.Get(0).(*dto.BackupRunResponse)
25051586 }
25061587 }
2507- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
2508- r1 = returnFunc(ctx, backupDomain, dbName)
1588+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
1589+ r1 = returnFunc(ctx, app, service, database)
25091590 } else {
25101591 r1 = ret.Error(1)
25111592 }
@@ -2519,13 +1600,14 @@ type MockControlPlane_RunBackup_Call struct {
25191600
25201601 // RunBackup is a helper method to define mock.On call
25211602 // - ctx context.Context
2522-// - backupDomain string
2523-// - dbName string
2524-func (_e *MockControlPlane_Expecter) RunBackup(ctx any, backupDomain any, dbName any) *MockControlPlane_RunBackup_Call {
2525- return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)}
1603+// - app string
1604+// - service string
1605+// - database string
1606+func (_e *MockControlPlane_Expecter) RunBackup(ctx any, app any, service any, database any) *MockControlPlane_RunBackup_Call {
1607+ return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, app, service, database)}
25261608 }
25271609
2528-func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, backupDomain string, dbName string)) *MockControlPlane_RunBackup_Call {
1610+func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, app string, service string, database string)) *MockControlPlane_RunBackup_Call {
25291611 _c.Call.Run(func(args mock.Arguments) {
25301612 var arg0 context.Context
25311613 if args[0] != nil {
@@ -2539,10 +1621,15 @@ func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, bac
25391621 if args[2] != nil {
25401622 arg2 = args[2].(string)
25411623 }
1624+ var arg3 string
1625+ if args[3] != nil {
1626+ arg3 = args[3].(string)
1627+ }
25421628 run(
25431629 arg0,
25441630 arg1,
25451631 arg2,
1632+ arg3,
25461633 )
25471634 })
25481635 return _c
@@ -2553,14 +1640,14 @@ func (_c *MockControlPlane_RunBackup_Call) Return(backupRunResponse *dto.BackupR
25531640 return _c
25541641 }
25551642
2556-func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, dbName string) (*dto.BackupRunResponse, error)) *MockControlPlane_RunBackup_Call {
1643+func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Context, app string, service string, database string) (*dto.BackupRunResponse, error)) *MockControlPlane_RunBackup_Call {
25571644 _c.Call.Return(run)
25581645 return _c
25591646 }
25601647
25611648 // RunVolumeBackups provides a mock function for the type MockControlPlane
2562-func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error) {
2563- ret := _mock.Called(ctx, backupDomain, volumeName)
1649+func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, app string, service string, volume string) (*dto.VolumeBackupRunResponse, error) {
1650+ ret := _mock.Called(ctx, app, service, volume)
25641651
25651652 if len(ret) == 0 {
25661653 panic("no return value specified for RunVolumeBackups")
@@ -2568,18 +1655,18 @@ func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomai
25681655
25691656 var r0 *dto.VolumeBackupRunResponse
25701657 var r1 error
2571- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.VolumeBackupRunResponse, error)); ok {
2572- return returnFunc(ctx, backupDomain, volumeName)
1658+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.VolumeBackupRunResponse, error)); ok {
1659+ return returnFunc(ctx, app, service, volume)
25731660 }
2574- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.VolumeBackupRunResponse); ok {
2575- r0 = returnFunc(ctx, backupDomain, volumeName)
1661+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.VolumeBackupRunResponse); ok {
1662+ r0 = returnFunc(ctx, app, service, volume)
25761663 } else {
25771664 if ret.Get(0) != nil {
25781665 r0 = ret.Get(0).(*dto.VolumeBackupRunResponse)
25791666 }
25801667 }
2581- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
2582- r1 = returnFunc(ctx, backupDomain, volumeName)
1668+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
1669+ r1 = returnFunc(ctx, app, service, volume)
25831670 } else {
25841671 r1 = ret.Error(1)
25851672 }
@@ -2593,13 +1680,14 @@ type MockControlPlane_RunVolumeBackups_Call struct {
25931680
25941681 // RunVolumeBackups is a helper method to define mock.On call
25951682 // - ctx context.Context
2596-// - backupDomain string
2597-// - volumeName string
2598-func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, backupDomain any, volumeName any) *MockControlPlane_RunVolumeBackups_Call {
2599- return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}
1683+// - app string
1684+// - service string
1685+// - volume string
1686+func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, app any, service any, volume any) *MockControlPlane_RunVolumeBackups_Call {
1687+ return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, app, service, volume)}
26001688 }
26011689
2602-func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string, volumeName string)) *MockControlPlane_RunVolumeBackups_Call {
1690+func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, app string, service string, volume string)) *MockControlPlane_RunVolumeBackups_Call {
26031691 _c.Call.Run(func(args mock.Arguments) {
26041692 var arg0 context.Context
26051693 if args[0] != nil {
@@ -2613,10 +1701,15 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Conte
26131701 if args[2] != nil {
26141702 arg2 = args[2].(string)
26151703 }
1704+ var arg3 string
1705+ if args[3] != nil {
1706+ arg3 = args[3].(string)
1707+ }
26161708 run(
26171709 arg0,
26181710 arg1,
26191711 arg2,
1712+ arg3,
26201713 )
26211714 })
26221715 return _c
@@ -2627,43 +1720,42 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Return(volumeBackupRunResponse
26271720 return _c
26281721 }
26291722
2630-func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call {
1723+func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, app string, service string, volume string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call {
26311724 _c.Call.Return(run)
26321725 return _c
26331726 }
26341727
2635-// SetAttachmentSecrets provides a mock function for the type MockControlPlane
2636-func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error {
2637- ret := _mock.Called(ctx, domain1, service, secrets)
1728+// SetAppSecrets provides a mock function for the type MockControlPlane
1729+func (_mock *MockControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
1730+ ret := _mock.Called(ctx, app, req)
26381731
26391732 if len(ret) == 0 {
2640- panic("no return value specified for SetAttachmentSecrets")
1733+ panic("no return value specified for SetAppSecrets")
26411734 }
26421735
26431736 var r0 error
2644- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok {
2645- r0 = returnFunc(ctx, domain1, service, secrets)
1737+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretSetRequest) error); ok {
1738+ r0 = returnFunc(ctx, app, req)
26461739 } else {
26471740 r0 = ret.Error(0)
26481741 }
26491742 return r0
26501743 }
26511744
2652-// MockControlPlane_SetAttachmentSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachmentSecrets'
2653-type MockControlPlane_SetAttachmentSecrets_Call struct {
1745+// MockControlPlane_SetAppSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAppSecrets'
1746+type MockControlPlane_SetAppSecrets_Call struct {
26541747 *mock.Call
26551748 }
26561749
2657-// SetAttachmentSecrets is a helper method to define mock.On call
1750+// SetAppSecrets is a helper method to define mock.On call
26581751 // - ctx context.Context
2659-// - domain1 string
2660-// - service string
2661-// - secrets map[string]string
2662-func (_e *MockControlPlane_Expecter) SetAttachmentSecrets(ctx any, domain1 any, service any, secrets any) *MockControlPlane_SetAttachmentSecrets_Call {
2663- return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)}
1752+// - app string
1753+// - req dto.AppSecretSetRequest
1754+func (_e *MockControlPlane_Expecter) SetAppSecrets(ctx any, app any, req any) *MockControlPlane_SetAppSecrets_Call {
1755+ return &MockControlPlane_SetAppSecrets_Call{Call: _e.mock.On("SetAppSecrets", ctx, app, req)}
26641756 }
26651757
2666-func (_c *MockControlPlane_SetAttachmentSecrets_Call) Run(run func(ctx context.Context, domain1 string, service string, secrets map[string]string)) *MockControlPlane_SetAttachmentSecrets_Call {
1758+func (_c *MockControlPlane_SetAppSecrets_Call) Run(run func(ctx context.Context, app string, req dto.AppSecretSetRequest)) *MockControlPlane_SetAppSecrets_Call {
26671759 _c.Call.Run(func(args mock.Arguments) {
26681760 var arg0 context.Context
26691761 if args[0] != nil {
@@ -2673,30 +1765,25 @@ func (_c *MockControlPlane_SetAttachmentSecrets_Call) Run(run func(ctx context.C
26731765 if args[1] != nil {
26741766 arg1 = args[1].(string)
26751767 }
2676- var arg2 string
1768+ var arg2 dto.AppSecretSetRequest
26771769 if args[2] != nil {
2678- arg2 = args[2].(string)
2679- }
2680- var arg3 map[string]string
2681- if args[3] != nil {
2682- arg3 = args[3].(map[string]string)
1770+ arg2 = args[2].(dto.AppSecretSetRequest)
26831771 }
26841772 run(
26851773 arg0,
26861774 arg1,
26871775 arg2,
2688- arg3,
26891776 )
26901777 })
26911778 return _c
26921779 }
26931780
2694-func (_c *MockControlPlane_SetAttachmentSecrets_Call) Return(err error) *MockControlPlane_SetAttachmentSecrets_Call {
1781+func (_c *MockControlPlane_SetAppSecrets_Call) Return(err error) *MockControlPlane_SetAppSecrets_Call {
26951782 _c.Call.Return(err)
26961783 return _c
26971784 }
26981785
2699-func (_c *MockControlPlane_SetAttachmentSecrets_Call) RunAndReturn(run func(ctx context.Context, domain1 string, service string, secrets map[string]string) error) *MockControlPlane_SetAttachmentSecrets_Call {
1786+func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppSecretSetRequest) error) *MockControlPlane_SetAppSecrets_Call {
27001787 _c.Call.Return(run)
27011788 return _c
27021789 }
@@ -2764,48 +1851,47 @@ func (_c *MockControlPlane_SetSecrets_Call) RunAndReturn(run func(ctx context.Co
27641851 return _c
27651852 }
27661853
2767-// StreamContainerLogs provides a mock function for the type MockControlPlane
2768-func (_mock *MockControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
2769- ret := _mock.Called(ctx, logDomain, lines)
1854+// ShowApp provides a mock function for the type MockControlPlane
1855+func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
1856+ ret := _mock.Called(ctx, app)
27701857
27711858 if len(ret) == 0 {
2772- panic("no return value specified for StreamContainerLogs")
1859+ panic("no return value specified for ShowApp")
27731860 }
27741861
2775- var r0 <-chan string
1862+ var r0 *dto.AppShowResponse
27761863 var r1 error
2777- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (<-chan string, error)); ok {
2778- return returnFunc(ctx, logDomain, lines)
1864+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppShowResponse, error)); ok {
1865+ return returnFunc(ctx, app)
27791866 }
2780- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) <-chan string); ok {
2781- r0 = returnFunc(ctx, logDomain, lines)
1867+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppShowResponse); ok {
1868+ r0 = returnFunc(ctx, app)
27821869 } else {
27831870 if ret.Get(0) != nil {
2784- r0 = ret.Get(0).(<-chan string)
1871+ r0 = ret.Get(0).(*dto.AppShowResponse)
27851872 }
27861873 }
2787- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
2788- r1 = returnFunc(ctx, logDomain, lines)
1874+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1875+ r1 = returnFunc(ctx, app)
27891876 } else {
27901877 r1 = ret.Error(1)
27911878 }
27921879 return r0, r1
27931880 }
27941881
2795-// MockControlPlane_StreamContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamContainerLogs'
2796-type MockControlPlane_StreamContainerLogs_Call struct {
1882+// MockControlPlane_ShowApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShowApp'
1883+type MockControlPlane_ShowApp_Call struct {
27971884 *mock.Call
27981885 }
27991886
2800-// StreamContainerLogs is a helper method to define mock.On call
1887+// ShowApp is a helper method to define mock.On call
28011888 // - ctx context.Context
2802-// - logDomain string
2803-// - lines int
2804-func (_e *MockControlPlane_Expecter) StreamContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_StreamContainerLogs_Call {
2805- return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)}
1889+// - app string
1890+func (_e *MockControlPlane_Expecter) ShowApp(ctx any, app any) *MockControlPlane_ShowApp_Call {
1891+ return &MockControlPlane_ShowApp_Call{Call: _e.mock.On("ShowApp", ctx, app)}
28061892 }
28071893
2808-func (_c *MockControlPlane_StreamContainerLogs_Call) Run(run func(ctx context.Context, logDomain string, lines int)) *MockControlPlane_StreamContainerLogs_Call {
1894+func (_c *MockControlPlane_ShowApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ShowApp_Call {
28091895 _c.Call.Run(func(args mock.Arguments) {
28101896 var arg0 context.Context
28111897 if args[0] != nil {
@@ -2815,78 +1901,153 @@ func (_c *MockControlPlane_StreamContainerLogs_Call) Run(run func(ctx context.Co
28151901 if args[1] != nil {
28161902 arg1 = args[1].(string)
28171903 }
2818- var arg2 int
2819- if args[2] != nil {
2820- arg2 = args[2].(int)
1904+ run(
1905+ arg0,
1906+ arg1,
1907+ )
1908+ })
1909+ return _c
1910+}
1911+
1912+func (_c *MockControlPlane_ShowApp_Call) Return(appShowResponse *dto.AppShowResponse, err error) *MockControlPlane_ShowApp_Call {
1913+ _c.Call.Return(appShowResponse, err)
1914+ return _c
1915+}
1916+
1917+func (_c *MockControlPlane_ShowApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppShowResponse, error)) *MockControlPlane_ShowApp_Call {
1918+ _c.Call.Return(run)
1919+ return _c
1920+}
1921+
1922+// StartApp provides a mock function for the type MockControlPlane
1923+func (_mock *MockControlPlane) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
1924+ ret := _mock.Called(ctx, app)
1925+
1926+ if len(ret) == 0 {
1927+ panic("no return value specified for StartApp")
1928+ }
1929+
1930+ var r0 *dto.AppDeployResponse
1931+ var r1 string
1932+ var r2 error
1933+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok {
1934+ return returnFunc(ctx, app)
1935+ }
1936+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok {
1937+ r0 = returnFunc(ctx, app)
1938+ } else {
1939+ if ret.Get(0) != nil {
1940+ r0 = ret.Get(0).(*dto.AppDeployResponse)
1941+ }
1942+ }
1943+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok {
1944+ r1 = returnFunc(ctx, app)
1945+ } else {
1946+ r1 = ret.Get(1).(string)
1947+ }
1948+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
1949+ r2 = returnFunc(ctx, app)
1950+ } else {
1951+ r2 = ret.Error(2)
1952+ }
1953+ return r0, r1, r2
1954+}
1955+
1956+// MockControlPlane_StartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartApp'
1957+type MockControlPlane_StartApp_Call struct {
1958+ *mock.Call
1959+}
1960+
1961+// StartApp is a helper method to define mock.On call
1962+// - ctx context.Context
1963+// - app string
1964+func (_e *MockControlPlane_Expecter) StartApp(ctx any, app any) *MockControlPlane_StartApp_Call {
1965+ return &MockControlPlane_StartApp_Call{Call: _e.mock.On("StartApp", ctx, app)}
1966+}
1967+
1968+func (_c *MockControlPlane_StartApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_StartApp_Call {
1969+ _c.Call.Run(func(args mock.Arguments) {
1970+ var arg0 context.Context
1971+ if args[0] != nil {
1972+ arg0 = args[0].(context.Context)
1973+ }
1974+ var arg1 string
1975+ if args[1] != nil {
1976+ arg1 = args[1].(string)
28211977 }
28221978 run(
28231979 arg0,
28241980 arg1,
2825- arg2,
28261981 )
28271982 })
28281983 return _c
28291984 }
28301985
2831-func (_c *MockControlPlane_StreamContainerLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamContainerLogs_Call {
2832- _c.Call.Return(stringCh, err)
1986+func (_c *MockControlPlane_StartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StartApp_Call {
1987+ _c.Call.Return(appDeployResponse, s, err)
28331988 return _c
28341989 }
28351990
2836-func (_c *MockControlPlane_StreamContainerLogs_Call) RunAndReturn(run func(ctx context.Context, logDomain string, lines int) (<-chan string, error)) *MockControlPlane_StreamContainerLogs_Call {
1991+func (_c *MockControlPlane_StartApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_StartApp_Call {
28371992 _c.Call.Return(run)
28381993 return _c
28391994 }
28401995
2841-// StreamProcessLogs provides a mock function for the type MockControlPlane
2842-func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
2843- ret := _mock.Called(ctx, lines)
1996+// StopApp provides a mock function for the type MockControlPlane
1997+func (_mock *MockControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
1998+ ret := _mock.Called(ctx, app)
28441999
28452000 if len(ret) == 0 {
2846- panic("no return value specified for StreamProcessLogs")
2001+ panic("no return value specified for StopApp")
28472002 }
28482003
2849- var r0 <-chan string
2850- var r1 error
2851- if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok {
2852- return returnFunc(ctx, lines)
2004+ var r0 *dto.AppDeployResponse
2005+ var r1 string
2006+ var r2 error
2007+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok {
2008+ return returnFunc(ctx, app)
28532009 }
2854- if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok {
2855- r0 = returnFunc(ctx, lines)
2010+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok {
2011+ r0 = returnFunc(ctx, app)
28562012 } else {
28572013 if ret.Get(0) != nil {
2858- r0 = ret.Get(0).(<-chan string)
2014+ r0 = ret.Get(0).(*dto.AppDeployResponse)
28592015 }
28602016 }
2861- if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2862- r1 = returnFunc(ctx, lines)
2017+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok {
2018+ r1 = returnFunc(ctx, app)
28632019 } else {
2864- r1 = ret.Error(1)
2020+ r1 = ret.Get(1).(string)
28652021 }
2866- return r0, r1
2022+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
2023+ r2 = returnFunc(ctx, app)
2024+ } else {
2025+ r2 = ret.Error(2)
2026+ }
2027+ return r0, r1, r2
28672028 }
28682029
2869-// MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs'
2870-type MockControlPlane_StreamProcessLogs_Call struct {
2030+// MockControlPlane_StopApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopApp'
2031+type MockControlPlane_StopApp_Call struct {
28712032 *mock.Call
28722033 }
28732034
2874-// StreamProcessLogs is a helper method to define mock.On call
2035+// StopApp is a helper method to define mock.On call
28752036 // - ctx context.Context
2876-// - lines int
2877-func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call {
2878- return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}
2037+// - app string
2038+func (_e *MockControlPlane_Expecter) StopApp(ctx any, app any) *MockControlPlane_StopApp_Call {
2039+ return &MockControlPlane_StopApp_Call{Call: _e.mock.On("StopApp", ctx, app)}
28792040 }
28802041
2881-func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockControlPlane_StreamProcessLogs_Call {
2042+func (_c *MockControlPlane_StopApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_StopApp_Call {
28822043 _c.Call.Run(func(args mock.Arguments) {
28832044 var arg0 context.Context
28842045 if args[0] != nil {
28852046 arg0 = args[0].(context.Context)
28862047 }
2887- var arg1 int
2048+ var arg1 string
28882049 if args[1] != nil {
2889- arg1 = args[1].(int)
2050+ arg1 = args[1].(string)
28902051 }
28912052 run(
28922053 arg0,
@@ -2896,54 +2057,65 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Cont
28962057 return _c
28972058 }
28982059
2899-func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call {
2900- _c.Call.Return(stringCh, err)
2060+func (_c *MockControlPlane_StopApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StopApp_Call {
2061+ _c.Call.Return(appDeployResponse, s, err)
29012062 return _c
29022063 }
29032064
2904-func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) (<-chan string, error)) *MockControlPlane_StreamProcessLogs_Call {
2065+func (_c *MockControlPlane_StopApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_StopApp_Call {
29052066 _c.Call.Return(run)
29062067 return _c
29072068 }
29082069
2909-// UpdateRoute provides a mock function for the type MockControlPlane
2910-func (_mock *MockControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
2911- ret := _mock.Called(ctx, route)
2070+// StreamProcessLogs provides a mock function for the type MockControlPlane
2071+func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
2072+ ret := _mock.Called(ctx, lines)
29122073
29132074 if len(ret) == 0 {
2914- panic("no return value specified for UpdateRoute")
2075+ panic("no return value specified for StreamProcessLogs")
29152076 }
29162077
2917- var r0 error
2918- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {
2919- r0 = returnFunc(ctx, route)
2078+ var r0 <-chan string
2079+ var r1 error
2080+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok {
2081+ return returnFunc(ctx, lines)
2082+ }
2083+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok {
2084+ r0 = returnFunc(ctx, lines)
29202085 } else {
2921- r0 = ret.Error(0)
2086+ if ret.Get(0) != nil {
2087+ r0 = ret.Get(0).(<-chan string)
2088+ }
29222089 }
2923- return r0
2090+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2091+ r1 = returnFunc(ctx, lines)
2092+ } else {
2093+ r1 = ret.Error(1)
2094+ }
2095+ return r0, r1
29242096 }
29252097
2926-// MockControlPlane_UpdateRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRoute'
2927-type MockControlPlane_UpdateRoute_Call struct {
2098+// MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs'
2099+type MockControlPlane_StreamProcessLogs_Call struct {
29282100 *mock.Call
29292101 }
29302102
2931-// UpdateRoute is a helper method to define mock.On call
2103+// StreamProcessLogs is a helper method to define mock.On call
29322104 // - ctx context.Context
2933-// - route domain.Route
2934-func (_e *MockControlPlane_Expecter) UpdateRoute(ctx any, route any) *MockControlPlane_UpdateRoute_Call {
2935- return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
2105+// - lines int
2106+func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call {
2107+ return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}
29362108 }
29372109
2938-func (_c *MockControlPlane_UpdateRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockControlPlane_UpdateRoute_Call {
2110+func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockControlPlane_StreamProcessLogs_Call {
29392111 _c.Call.Run(func(args mock.Arguments) {
29402112 var arg0 context.Context
29412113 if args[0] != nil {
29422114 arg0 = args[0].(context.Context)
29432115 }
2944- var arg1 domain.Route
2116+ var arg1 int
29452117 if args[1] != nil {
2946- arg1 = args[1].(domain.Route)
2118+ arg1 = args[1].(int)
29472119 }
29482120 run(
29492121 arg0,
@@ -2953,12 +2125,12 @@ func (_c *MockControlPlane_UpdateRoute_Call) Run(run func(ctx context.Context, r
29532125 return _c
29542126 }
29552127
2956-func (_c *MockControlPlane_UpdateRoute_Call) Return(err error) *MockControlPlane_UpdateRoute_Call {
2957- _c.Call.Return(err)
2128+func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call {
2129+ _c.Call.Return(stringCh, err)
29582130 return _c
29592131 }
29602132
2961-func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockControlPlane_UpdateRoute_Call {
2133+func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) (<-chan string, error)) *MockControlPlane_StreamProcessLogs_Call {
29622134 _c.Call.Return(run)
29632135 return _c
29642136 }
M internal/adapters/in/cli/networks.go +1 -1
@@ -37,7 +37,7 @@ Examples:
3737 gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
3838 RunE: func(cmd *cobra.Command, _ []string) error {
3939 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)
40+ handle, err := resolveControlPlane(cliConfigPath)
4141 if err != nil {
4242 return err
4343 }
D internal/adapters/in/cli/parity_matrix_test.go +0 -91
@@ -1,91 +0,0 @@
1-package cli
2-
3-import (
4- "go/ast"
5- "go/parser"
6- "go/token"
7- "os"
8- "strings"
9- "testing"
10-)
11-
12-type uiAdoptionExpectation struct {
13- family string
14- file string
15- functions []string
16-}
17-
18-var uiAdoptionExpectations = []uiAdoptionExpectation{
19- {
20- family: "root/server",
21- file: "root.go",
22- functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
23- },
24- {
25- family: "backups",
26- file: "backup.go",
27- functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
28- },
29- {
30- family: "images",
31- file: "images.go",
32- functions: []string{"runImagesList", "runImagesPrune"},
33- },
34-}
35-
36-func TestLocalParityMatrix(t *testing.T) {
37- checks := []struct {
38- file string
39- legacyText string
40- command string
41- }{
42- {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
43- // Guards against reintroducing the old rollback-only remote-mode error text during the command rename.
44- {file: "pin.go", legacyText: "rollback requires remote mode", command: "gordon pin"},
45- {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
46- {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
47- {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
48- }
49-
50- for _, check := range checks {
51- content, err := os.ReadFile(check.file)
52- if err != nil {
53- t.Fatalf("failed to read %s: %v", check.file, err)
54- }
55- if strings.Contains(string(content), check.legacyText) {
56- t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file)
57- }
58- }
59-}
60-
61-func TestUIAdoptionMatrixCoverage(t *testing.T) {
62- for _, expect := range uiAdoptionExpectations {
63- t.Run(expect.family, func(t *testing.T) {
64- if len(expect.functions) == 0 {
65- t.Fatalf("ui adoption matrix for %s is empty", expect.family)
66- }
67-
68- fset := token.NewFileSet()
69- fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
70- if err != nil {
71- t.Fatalf("failed to parse %s: %v", expect.file, err)
72- }
73-
74- found := make(map[string]bool, len(expect.functions))
75- ast.Inspect(fileNode, func(n ast.Node) bool {
76- fn, ok := n.(*ast.FuncDecl)
77- if !ok {
78- return true
79- }
80- found[fn.Name.Name] = true
81- return true
82- })
83-
84- for _, fn := range expect.functions {
85- if !found[fn] {
86- t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
87- }
88- }
89- })
90- }
91-}
D internal/adapters/in/cli/pin.go +0 -249
@@ -1,249 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "slices"
8- "sort"
9- "strings"
10-
11- "github.com/spf13/cobra"
12- "golang.org/x/mod/semver"
13-
14- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16- "github.com/bnema/gordon/internal/domain"
17-)
18-
19-func newPinCmd() *cobra.Command {
20- var targetTag string
21-
22- cmd := &cobra.Command{
23- Use: "pin <domain>",
24- Short: "Pin a route to a specific image tag",
25- Long: `Lists available image tags for a domain and deploys the selected version.
26-Tags are read from the Gordon registry.
27-
28-Examples:
29- gordon pin myapp.example.com --remote ...
30- gordon pin myapp.example.com --tag v1.0.0 --remote ...
31- gordon pin list myapp.example.com --remote ...`,
32- Args: cobra.ExactArgs(1),
33- RunE: func(cmd *cobra.Command, args []string) error {
34- return runPin(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], targetTag)
35- },
36- }
37-
38- cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39- cmd.AddCommand(newPinListCmd())
40-
41- return cmd
42-}
43-
44-func newPinListCmd() *cobra.Command {
45- var jsonOut bool
46-
47- cmd := &cobra.Command{
48- Use: "list <domain>",
49- Short: "List available tags",
50- Args: cobra.ExactArgs(1),
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return runPinList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
53- },
54- }
55-
56- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57-
58- return cmd
59-}
60-
61-func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
62- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
63- if err != nil {
64- return fmt.Errorf("failed to resolve control plane: %w", err)
65- }
66- defer handle.close()
67-
68- route, err := handle.plane.GetRoute(ctx, pinDomain)
69- if err != nil {
70- return fmt.Errorf("failed to get route: %w", err)
71- }
72-
73- _, imageName, currentTag := parseImageRef(route.Image)
74- if imageName == "" {
75- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
76- }
77-
78- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
79- if err != nil {
80- return fmt.Errorf("failed to fetch tags: %w", err)
81- }
82-
83- selectedTag, err := selectTag(targetTag, tags, currentTag, pinDomain, out)
84- if err != nil {
85- return fmt.Errorf("failed to select tag: %w", err)
86- }
87- if selectedTag == "" {
88- return nil
89- }
90-
91- if selectedTag == currentTag {
92- return cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
93- }
94-
95- return deploySelectedTag(ctx, handle.plane, route, out, ew, pinDomain, imageName, selectedTag)
96-}
97-
98-func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
99- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
100- if err != nil {
101- return fmt.Errorf("failed to resolve control plane: %w", err)
102- }
103- defer handle.close()
104-
105- route, err := handle.plane.GetRoute(ctx, pinDomain)
106- if err != nil {
107- return fmt.Errorf("failed to get route: %w", err)
108- }
109-
110- _, imageName, currentTag := parseImageRef(route.Image)
111- if imageName == "" {
112- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
113- }
114-
115- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
116- if err != nil {
117- return fmt.Errorf("failed to fetch tags: %w", err)
118- }
119-
120- return printPinTags(w, pinDomain, currentTag, tags, jsonOut)
121-}
122-
123-func printPinTags(w io.Writer, pinDomain, currentTag string, tags []string, jsonOut bool) error {
124- if jsonOut {
125- return writeJSON(w, map[string]any{
126- "domain": pinDomain,
127- "current_tag": currentTag,
128- "tags": tags,
129- })
130- }
131-
132- if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(pinDomain)); err != nil {
133- return err
134- }
135- for _, tag := range tags {
136- suffix := ""
137- if tag == currentTag {
138- suffix = " (current)"
139- }
140- if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil {
141- return err
142- }
143- }
144- return nil
145-}
146-
147-func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
148- tags, err := cp.ListTags(ctx, imageName)
149- if err != nil {
150- return nil, fmt.Errorf("failed to list tags: %w", err)
151- }
152- if len(tags) == 0 {
153- return nil, fmt.Errorf("no tags found for %s", imageName)
154- }
155-
156- return sortSemverTags(tags), nil
157-}
158-
159-// sortSemverTags normalizes, classifies, and sorts tags.
160-// Semver tags (with or without 'v' prefix) are sorted in descending order first,
161-// followed by non-semver tags in descending lexicographic order.
162-func sortSemverTags(tags []string) []string {
163- var semverTags, otherTags []string
164- for _, tag := range tags {
165- sv := tag
166- if !strings.HasPrefix(sv, "v") {
167- sv = "v" + sv
168- }
169- if semver.IsValid(sv) {
170- semverTags = append(semverTags, tag)
171- } else {
172- otherTags = append(otherTags, tag)
173- }
174- }
175-
176- // Sort semver tags in descending order (latest first)
177- sort.Slice(semverTags, func(i, j int) bool {
178- si, sj := semverTags[i], semverTags[j]
179- if !strings.HasPrefix(si, "v") {
180- si = "v" + si
181- }
182- if !strings.HasPrefix(sj, "v") {
183- sj = "v" + sj
184- }
185- return semver.Compare(si, sj) > 0
186- })
187-
188- // Sort non-semver tags in descending lexicographic order
189- sort.Slice(otherTags, func(i, j int) bool {
190- return otherTags[i] > otherTags[j]
191- })
192-
193- // Combine: semver tags first, then other tags
194- return append(semverTags, otherTags...)
195-}
196-
197-func selectTag(targetTag string, tags []string, currentTag, pinDomain string, out io.Writer) (string, error) {
198- if targetTag != "" {
199- if !validateTagExists(targetTag, tags) {
200- return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
201- }
202- return targetTag, nil
203- }
204-
205- selectedTag, err := components.RunSelector(
206- fmt.Sprintf("Select version for %s:", pinDomain),
207- tags,
208- currentTag,
209- )
210- if err != nil {
211- return "", err
212- }
213- if selectedTag == "" {
214- _ = cliWriteLine(out, "Cancelled.")
215- }
216- return selectedTag, nil
217-}
218-
219-func validateTagExists(targetTag string, tags []string) bool {
220- return slices.Contains(tags, targetTag)
221-}
222-
223-func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, out, ew io.Writer, pinDomain, imageName, selectedTag string) error {
224- registry, _, _ := parseImageRef(route.Image)
225- oldImage := route.Image
226- route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
227-
228- if err := cliWritef(out, "Pinning to %s...\n", styles.Theme.Bold.Render(selectedTag)); err != nil {
229- return err
230- }
231-
232- if err := cp.UpdateRoute(ctx, *route); err != nil {
233- return fmt.Errorf("failed to update route: %w", err)
234- }
235-
236- result, err := cp.Deploy(ctx, pinDomain)
237- if err != nil {
238- formattedErr := formatDeployFailure(err)
239- // Attempt to revert route to previous image
240- route.Image = oldImage
241- if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242- _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243- return fmt.Errorf("failed to deploy; revert failed: %w; deploy error: %v", revertErr, formattedErr)
244- }
245- return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr)
246- }
247- containerID := shortContainerID(result.ContainerID)
248- return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
249-}
D internal/adapters/in/cli/pin_test.go +0 -104
@@ -1,104 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "testing"
8-
9- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
10- "github.com/bnema/gordon/internal/domain"
11- "github.com/stretchr/testify/assert"
12-)
13-
14-type failingWriter struct{}
15-
16-func (failingWriter) Write(_ []byte) (int, error) {
17- return 0, errors.New("write failed")
18-}
19-
20-func TestSortSemverTags(t *testing.T) {
21- tests := []struct {
22- name string
23- tags []string
24- wantTags []string
25- }{
26- {
27- name: "v-prefixed tags sorted descending",
28- tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"},
29- wantTags: []string{"v2.0.0", "v1.5.0", "v1.0.0"},
30- },
31- {
32- name: "non-v-prefixed tags sorted as semver",
33- tags: []string{"1.0.0", "2.0.0", "1.5.0"},
34- wantTags: []string{"2.0.0", "1.5.0", "1.0.0"},
35- },
36- {
37- name: "mixed v and non-v prefixed",
38- tags: []string{"v1.0.0", "2.0.0", "1.5.0"},
39- wantTags: []string{"2.0.0", "1.5.0", "v1.0.0"}, // all recognized as semver, sorted desc
40- },
41- {
42- name: "pre-release tags",
43- tags: []string{"v1.0.0-rc1", "v1.0.0", "1.0.0-beta"},
44- wantTags: []string{"v1.0.0", "v1.0.0-rc1", "1.0.0-beta"}, // release > pre-release
45- },
46- {
47- name: "non-semver tags appended after",
48- tags: []string{"latest", "v1.0.0", "dev"},
49- wantTags: []string{"v1.0.0", "latest", "dev"},
50- },
51- }
52-
53- for _, tt := range tests {
54- t.Run(tt.name, func(t *testing.T) {
55- gotTags := sortSemverTags(tt.tags)
56- assert.Equal(t, tt.wantTags, gotTags)
57- })
58- }
59-}
60-
61-func TestValidateTagExists(t *testing.T) {
62- assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
63- assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
64-}
65-
66-func TestPrintPinTags_WritesToProvidedWriter(t *testing.T) {
67- var buf bytes.Buffer
68-
69- err := printPinTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
70- assert.NoError(t, err)
71-
72- output := buf.String()
73- assert.Contains(t, output, "Available tags for")
74- assert.Contains(t, output, "myapp.example.com")
75- assert.Contains(t, output, "- v1.2.0 (current)")
76- assert.Contains(t, output, "- v1.1.0")
77-}
78-
79-func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
80- err := printPinTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
81- assert.Error(t, err)
82- assert.Contains(t, err.Error(), "write failed")
83-}
84-
85-func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
86- cp := climocks.NewMockControlPlane(t)
87- route := &domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}
88- deployErr := &domain.DeployFailureError{
89- Summary: "failed to deploy",
90- Cause: "health check failed",
91- Hint: "check DATABASE_URL",
92- }
93-
94- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2"}).Return(nil).Once()
95- cp.EXPECT().Deploy(context.Background(), "app.example.com").Return(nil, deployErr).Once()
96- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}).Return(nil).Once()
97-
98- err := deploySelectedTag(context.Background(), cp, route, &bytes.Buffer{}, &bytes.Buffer{}, "app.example.com", "myapp", "v2")
99- assert.Error(t, err)
100- assert.Contains(t, err.Error(), "failed to deploy")
101- assert.Contains(t, err.Error(), "Cause: health check failed")
102- assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103- assert.Contains(t, err.Error(), "route reverted to previous image")
104-}
M internal/adapters/in/cli/presentation.go +0 -8
@@ -63,11 +63,3 @@ func cliRenderWarning(msg string) string {
6363 func cliRenderInfo(msg string) string {
6464 return styles.RenderInfo(msg)
6565 }
66-
67-func shortContainerID(id string) string {
68- if len(id) > 12 {
69- return id[:12]
70- }
71-
72- return id
73-}
D internal/adapters/in/cli/preview.go +0 -469
@@ -1,469 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "os"
8- "os/exec"
9- "strings"
10- "text/tabwriter"
11- "time"
12-
13- tea "github.com/charmbracelet/bubbletea"
14- "github.com/spf13/cobra"
15-
16- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18- "github.com/bnema/gordon/internal/domain"
19- "github.com/bnema/gordon/internal/usecase/auto/preview"
20-)
21-
22-func newPreviewCmd() *cobra.Command {
23- cmd := &cobra.Command{
24- Use: "preview",
25- Short: "Create or manage preview environments",
26- Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
27- }
28-
29- createCmd := newPreviewCreateCmd()
30-
31- cmd.AddCommand(
32- createCmd,
33- newPreviewListCmd(),
34- newPreviewDeleteCmd(),
35- newPreviewExtendCmd(),
36- )
37-
38- return cmd
39-}
40-
41-func newPreviewCreateCmd() *cobra.Command {
42- var (
43- ttl string
44- noBuild bool
45- noData bool
46- platform string
47- )
48-
49- cmd := &cobra.Command{
50- Use: "create [name]",
51- Short: "Create a preview environment",
52- Args: cobra.MaximumNArgs(1),
53- RunE: func(cmd *cobra.Command, args []string) error {
54- ctx := cmd.Context()
55- out := cmd.OutOrStdout()
56-
57- // Resolve preview name from arg or git branch.
58- name, err := resolvePreviewName(ctx, args)
59- if err != nil {
60- return err
61- }
62-
63- previewTag := "preview-" + name
64-
65- if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil {
66- return err
67- }
68-
69- // Resolve control plane (remote or local) — same as push.
70- handle, err := resolveControlPlane(configPath)
71- if err != nil {
72- return err
73- }
74- defer handle.close()
75-
76- previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag)
77- if err != nil {
78- return err
79- }
80-
81- if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil {
82- return err
83- }
84-
85- return waitForPreview(ctx, out, name, ttl, noData)
86- },
87- }
88-
89- cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
90- cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
91- cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
92- cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
93- return cmd
94-}
95-
96-func newPreviewListCmd() *cobra.Command {
97- var jsonOutput bool
98- cmd := &cobra.Command{
99- Use: "list",
100- Short: "List active preview environments",
101- RunE: func(cmd *cobra.Command, args []string) error {
102- ctx := cmd.Context()
103- out := cmd.OutOrStdout()
104-
105- client, isRemote, err := GetRemoteClient()
106- if err != nil {
107- return err
108- }
109- if !isRemote {
110- return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
111- }
112-
113- previews, err := client.ListPreviews(ctx)
114- if err != nil {
115- return fmt.Errorf("failed to list previews: %w", err)
116- }
117-
118- if jsonOutput {
119- return writeJSON(out, previews)
120- }
121-
122- if len(previews) == 0 {
123- return cliWriteLine(out, cliRenderEmptyState("No active previews"))
124- }
125-
126- return printPreviewTable(out, previews)
127- },
128- }
129- cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
130- return cmd
131-}
132-
133-func newPreviewDeleteCmd() *cobra.Command {
134- return &cobra.Command{
135- Use: "delete <name>",
136- Short: "Delete a preview environment",
137- Args: cobra.ExactArgs(1),
138- RunE: func(cmd *cobra.Command, args []string) error {
139- ctx := cmd.Context()
140- out := cmd.OutOrStdout()
141- name := args[0]
142-
143- client, isRemote, err := GetRemoteClient()
144- if err != nil {
145- return err
146- }
147- if !isRemote {
148- return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
149- }
150-
151- if err := client.DeletePreview(ctx, name); err != nil {
152- return fmt.Errorf("failed to delete preview %q: %w", name, err)
153- }
154-
155- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
156- },
157- }
158-}
159-
160-func newPreviewExtendCmd() *cobra.Command {
161- var ttl string
162- cmd := &cobra.Command{
163- Use: "extend <name>",
164- Short: "Extend a preview environment's TTL",
165- Args: cobra.ExactArgs(1),
166- RunE: func(cmd *cobra.Command, args []string) error {
167- ctx := cmd.Context()
168- out := cmd.OutOrStdout()
169- name := args[0]
170-
171- client, isRemote, err := GetRemoteClient()
172- if err != nil {
173- return err
174- }
175- if !isRemote {
176- return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
177- }
178-
179- if err := client.ExtendPreview(ctx, name, ttl); err != nil {
180- return fmt.Errorf("failed to extend preview %q: %w", name, err)
181- }
182-
183- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
184- },
185- }
186- cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration")
187- return cmd
188-}
189-
190-func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error {
191- w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
192- if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
193- return err
194- }
195- now := time.Now()
196- for _, p := range previews {
197- remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
198- expiresStr := remaining.String()
199- if p.IsExpired(now) {
200- expiresStr = "expired"
201- }
202- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
203- p.Name,
204- p.Domain,
205- truncateImage(p.Image, 40),
206- p.CreatedAt.Format(time.DateTime),
207- expiresStr,
208- ); err != nil {
209- return err
210- }
211- }
212- return w.Flush()
213-}
214-
215-func resolvePreviewName(ctx context.Context, args []string) (string, error) {
216- if len(args) > 0 {
217- name := preview.SanitizeBranchName(args[0])
218- if name == "" {
219- return "", fmt.Errorf("invalid preview name after sanitization: %q", args[0])
220- }
221- return name, nil
222- }
223- branch, err := detectGitBranch(ctx)
224- if err != nil {
225- return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err)
226- }
227- name := preview.SanitizeBranchName(branch)
228- if name == "" {
229- return "", fmt.Errorf("git branch %q produced empty preview name after sanitization", branch)
230- }
231- return name, nil
232-}
233-
234-func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) {
235- dockerfile := "Dockerfile"
236- imageName, err := detectImageName(dockerfile)
237- if err != nil {
238- return "", err
239- }
240- if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil {
241- return "", err
242- }
243-
244- // Resolve registry from existing routes without showing the interactive
245- // selector — preview only needs the registry URL, not a target domain.
246- registry, err := resolveRegistryForImage(ctx, cp, imageName)
247- if err != nil {
248- return "", err
249- }
250-
251- previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
252- if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil {
253- return "", err
254- }
255- return previewRef, nil
256-}
257-
258-// resolveRegistryForImage finds the registry URL from any existing non-preview
259-// route for the given image, without prompting.
260-func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
261- routes, err := cp.FindRoutesByImage(ctx, imageName)
262- if err != nil {
263- return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
264- }
265-
266- for _, r := range routes {
267- if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
268- continue
269- }
270- reg, _, _ := parseImageRef(r.Image)
271- if reg != "" {
272- return reg, nil
273- }
274- }
275-
276- return "", fmt.Errorf("no route configured for image %q", imageName)
277-}
278-
279-func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
280- imageOps, err := newImageOpsFn()
281- if err != nil {
282- return err
283- }
284-
285- if !noBuild {
286- dockerfile := "Dockerfile"
287- if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
288- return fmt.Errorf("dockerfile not found: %s", dockerfile)
289- }
290- buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef)
291- if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
292- return err
293- }
294- if err := imageOps.Build(ctx, buildArgs); err != nil {
295- return err
296- }
297- }
298-
299- if err := cliWriteLine(out, "Pushing..."); err != nil {
300- return err
301- }
302- if err := imageOps.Push(ctx, previewRef); err != nil {
303- return fmt.Errorf("failed to push %s: %w", previewRef, err)
304- }
305- return nil
306-}
307-
308-func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
309- if ttl != "" {
310- if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
311- return err
312- }
313- }
314- if noData {
315- if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil {
316- return err
317- }
318- }
319- return nil
320-}
321-
322-func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
323- if err := printPreviewFlags(out, ttl, noData); err != nil {
324- return err
325- }
326-
327- client, isRemote, err := GetRemoteClient()
328- if err != nil {
329- return err
330- }
331- if !isRemote {
332- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
333- }
334-
335- result, err := pollPreviewWithSpinner(ctx, out, client, name)
336- if err != nil {
337- return err
338- }
339-
340- switch result.Status {
341- case domain.PreviewStatusRunning:
342- scheme := "http"
343- if result.HTTPS {
344- scheme = "https"
345- }
346- previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
347- if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
348- return err
349- }
350- return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
351- case domain.PreviewStatusFailed:
352- return fmt.Errorf("preview deployment failed")
353- default:
354- return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
355- }
356-}
357-
358-type previewPollResult struct {
359- preview *domain.PreviewRoute
360- err error
361-}
362-
363-type previewPollDoneMsg previewPollResult
364-
365-type previewSpinnerModel struct {
366- spinner components.SpinnerModel
367- done <-chan previewPollResult
368- outcome previewPollResult
369- finished bool
370-}
371-
372-func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
373- return previewSpinnerModel{
374- spinner: components.NewSpinner(
375- components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
376- components.WithSpinnerType(components.SpinnerMiniDot),
377- ),
378- done: done,
379- }
380-}
381-
382-func (m previewSpinnerModel) Init() tea.Cmd {
383- return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
384-}
385-
386-func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387- switch msg := msg.(type) {
388- case previewPollDoneMsg:
389- m.outcome = previewPollResult(msg)
390- m.finished = true
391- return m, tea.Quit
392- default:
393- updated, cmd := m.spinner.Update(msg)
394- if sm, ok := updated.(components.SpinnerModel); ok {
395- m.spinner = sm
396- }
397- return m, cmd
398- }
399-}
400-
401-func (m previewSpinnerModel) View() string {
402- return m.spinner.View()
403-}
404-
405-func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
406- return func() tea.Msg {
407- return previewPollDoneMsg(<-done)
408- }
409-}
410-
411-func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
412- done := make(chan previewPollResult, 1)
413- go func() {
414- result, err := pollPreviewStatus(ctx, client, name)
415- done <- previewPollResult{preview: result, err: err}
416- }()
417-
418- if !isInteractiveTerminal() {
419- if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
420- return nil, err
421- }
422- r := <-done
423- return r.preview, r.err
424- }
425-
426- model := newPreviewSpinnerModel(name, done)
427- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
428- fmt.Print("\r\033[K")
429- if err != nil {
430- return nil, err
431- }
432-
433- m, ok := final.(previewSpinnerModel)
434- if !ok || !m.finished {
435- return nil, fmt.Errorf("preview spinner exited unexpectedly")
436- }
437- return m.outcome.preview, m.outcome.err
438-}
439-
440-func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
441- ticker := time.NewTicker(2 * time.Second)
442- defer ticker.Stop()
443- timeout := time.After(3 * time.Minute)
444-
445- for {
446- select {
447- case <-ctx.Done():
448- return nil, ctx.Err()
449- case <-timeout:
450- return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
451- case <-ticker.C:
452- p, err := client.GetPreview(ctx, name)
453- if err != nil {
454- continue
455- }
456- if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
457- return p, nil
458- }
459- }
460- }
461-}
462-
463-func detectGitBranch(ctx context.Context) (string, error) {
464- out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
465- if err != nil {
466- return "", fmt.Errorf("detect git branch: %w", err)
467- }
468- return strings.TrimSpace(string(out)), nil
469-}
A internal/adapters/in/cli/prune.go +76 -0
@@ -0,0 +1,76 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/dto"
8+ "github.com/bnema/gordon/internal/domain"
9+ "github.com/bnema/gordon/pkg/bytesize"
10+)
11+
12+// cliRenderPruneSummary writes the shared prune verdict summary: counts
13+// per verdict, what was removed, and the reasons protected or unknown
14+// candidates were skipped. Dry-run and execution use the same renderer.
15+func cliRenderPruneSummary(out io.Writer, summary dto.PruneSummary) error {
16+ action := "Deleted"
17+ if !summary.Applied {
18+ action = "Would delete"
19+ }
20+ // The wire summary carries the authoritative counts, so the human
21+ // output can never disagree with the JSON body.
22+ actionCount := len(summary.Deleted)
23+ if !summary.Applied {
24+ actionCount = summary.Eligible
25+ }
26+ if err := cliWritef(out, "%s: %d (eligible=%d protected=%d unknown=%d)\n",
27+ action, actionCount, summary.Eligible, summary.Protected, summary.Unknown); err != nil {
28+ return err
29+ }
30+ if summary.ReclaimedKnown {
31+ if err := cliWritef(out, "Space reclaimed: %s\n", bytesize.Format(summary.ReclaimedBytes)); err != nil {
32+ return err
33+ }
34+ }
35+ for _, failure := range summary.Failures {
36+ line := fmt.Sprintf("failed to delete %s %s: %s", failure.Kind, failure.Ref, failure.Error)
37+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
38+ return err
39+ }
40+ }
41+ for _, gap := range summary.Gaps {
42+ line := fmt.Sprintf("inventory incomplete (%s: %s); dependent candidates were left untouched", gap.Source, gap.Reason)
43+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
44+ return err
45+ }
46+ }
47+ return cliRenderSkippedCandidates(out, summary.Candidates)
48+}
49+
50+// cliRenderSkippedCandidates lists the not-deleted candidates with their
51+// first reason, so an operator can see why each resource survived.
52+func cliRenderSkippedCandidates(out io.Writer, candidates []dto.PruneCandidate) error {
53+ skipped := make([]dto.PruneCandidate, 0, len(candidates))
54+ for _, candidate := range candidates {
55+ if candidate.Verdict == string(domain.PruneVerdictEligible) {
56+ continue
57+ }
58+ skipped = append(skipped, candidate)
59+ }
60+ if len(skipped) == 0 {
61+ return nil
62+ }
63+ if err := cliWriteLine(out, cliRenderMuted("Skipped:")); err != nil {
64+ return err
65+ }
66+ for _, candidate := range skipped {
67+ reason := ""
68+ if len(candidate.Reasons) > 0 {
69+ reason = candidate.Reasons[0]
70+ }
71+ if err := cliWritef(out, " %s %s (%s) %s\n", candidate.Kind, candidate.Ref, candidate.Verdict, reason); err != nil {
72+ return err
73+ }
74+ }
75+ return nil
76+}
M internal/adapters/in/cli/push.go +67 -379
@@ -6,7 +6,6 @@ import (
66 "errors"
77 "fmt"
88 "io"
9- "net/http"
109 "os"
1110 "os/exec"
1211 "path/filepath"
@@ -14,11 +13,9 @@ import (
1413 "strings"
1514 "time"
1615
17- tea "github.com/charmbracelet/bubbletea"
1816 "github.com/spf13/cobra"
1917
2018 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
2219 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
2320 "github.com/bnema/gordon/internal/domain"
2421 "github.com/bnema/gordon/pkg/validation"
@@ -38,6 +35,7 @@ type buildConfig struct {
3835 type imagePush struct {
3936 Registry string
4037 ImageName string
38+ SourceRef string
4139 Version string
4240 VersionRef string
4341 LatestRef string
@@ -45,47 +43,32 @@ type imagePush struct {
4543
4644 // pushRequest holds all inputs for the push command.
4745 type pushRequest struct {
48- ImageArg string
49- Domain string
50- Tag string
51- Build buildConfig
52- NoDeploy bool
53- NoConfirm bool
46+ ImageArg string
47+ Tag string
48+ Build buildConfig
5449 }
5550
5651 func newPushCmd() *cobra.Command {
5752 var (
58- noDeploy bool
59- noConfirm bool
6053 build bool
6154 platform string
6255 tag string
6356 dockerfile string
6457 buildArgs []string
65- domainFlag string
6658 )
6759
6860 cmd := &cobra.Command{
6961 Use: "push [image]",
70- Short: "Tag, push, and optionally deploy an image",
62+ Short: "Tag and push an image to the Gordon registry",
7163 Long: `Tags a local image for the Gordon registry and pushes it.
72-Uses git tags for versioning. Optionally triggers deployment after push.
73-
74-Image-first syntax is primary:
75- - Positional args resolve routes by image name first
76- - Tagged positional refs still resolve routes by image name
77- - The pushed version comes from --tag, CI tag refs, or git describe
78- - Dotted positional refs fall back to legacy domain lookup if no image route exists
79- - --domain is a deploy target override for legacy workflows
80- - No-arg mode still auto-detects from Dockerfile labels or current directory name
81-
82-With --build, builds the image first using docker buildx.
64+Uses git tags for versioning. Push transfers OCI content only: it never
65+deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
66+the manifest that references the pushed tag.
8367
8468 Examples:
8569 gordon push myapp --build --remote ...
86- gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...
70+ gordon push myapp:v1.2.3 --tag v1.2.3 --remote ...
8771 gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
88- gordon push --domain myapp.example.com --build --remote ...
8972 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
9073 Args: cobra.MaximumNArgs(1),
9174 RunE: func(cmd *cobra.Command, args []string) error {
@@ -94,24 +77,18 @@ Examples:
9477 imageArg = args[0]
9578 }
9679 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
97- ImageArg: imageArg,
98- Domain: domainFlag,
99- Tag: tag,
100- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
101- NoDeploy: noDeploy,
102- NoConfirm: noConfirm,
80+ ImageArg: imageArg,
81+ Tag: tag,
82+ Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
10383 })
10484 },
10585 }
10686
107- cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
108- cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
10987 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
11088 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
11189 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
11290 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
11391 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
114- cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
11592
11693 return cmd
11794 }
@@ -141,7 +118,8 @@ const (
141118
142119 type classifiedPushArg struct {
143120 kind pushArgKind
144- lookupImage string
121+ sourceRef string
122+ repository string
145123 legacyDomain string
146124 }
147125
@@ -150,11 +128,11 @@ func classifyPushArgument(arg string) classifiedPushArg {
150128 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
151129 }
152130
153- classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}
154- if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {
155- classified.lookupImage = parsedImage
131+ _, repository, _ := parseImageRef(arg)
132+ if repository == "" {
133+ repository, _ = validation.ParseImageReference(arg)
156134 }
157- return classified
135+ return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
158136 }
159137
160138 func looksLikeLegacyDomain(arg string) bool {
@@ -168,51 +146,29 @@ func looksLikeLegacyDomain(arg string) bool {
168146 return strings.Contains(host, ".")
169147 }
170148
171-// resolveRoute determines the registry, image name, and domain from the input mode.
172-func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
173- if domainFlag != "" {
174- return resolveFromDomain(ctx, cp, domainFlag)
175- }
176-
149+// resolveImageTarget determines the registry and image name from the input.
150+// Push transfers OCI content only and never triggers deploys or route
151+// lookups. The deploy target comes from the app manifest applied
152+// separately via `gordon apps apply`.
153+func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
177154 if imageArg == "" {
178155 imageArg, err = detectImageName(dockerfile)
179156 if err != nil {
180157 return "", "", "", err
181158 }
182- fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
183- }
184-
185- if looksLikeLegacyDomain(imageArg) {
186- lookupImage := imageArg
187- domainLookupArg := imageArg
188- if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
189- domainLookupArg = parsedImage
190- if !strings.HasPrefix(ref, "sha256:") {
191- lookupImage = parsedImage
192- }
193- }
194-
195- registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
196- if err == nil {
197- return registry, imageName, pushDomain, nil
198- }
199- if !isImageBootstrapError(err) {
159+ if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
200160 return "", "", "", err
201161 }
202-
203- registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
204- if err == nil {
205- return registry, imageName, pushDomain, nil
206- }
207- if errors.Is(err, domain.ErrRouteNotFound) {
208- return "", "", "", noRouteForImageError(domainLookupArg)
209- }
210- return "", "", "", err
211162 }
212-
163+ if looksLikeLegacyDomain(imageArg) {
164+ return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165+ }
213166 classified := classifyPushArgument(imageArg)
214-
215- return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)
167+ registry, imageName, _ = parseImageRef(classified.sourceRef)
168+ if registry == "" || imageName == "" {
169+ return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
170+ }
171+ return registry, imageName, classified.sourceRef, nil
216172 }
217173
218174 // resolveVersion determines and validates the version tag.
@@ -233,7 +189,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
233189 }
234190 defer handle.close()
235191
236- registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
192+ registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
237193 if err != nil {
238194 return err
239195 }
@@ -246,7 +202,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
246202 return err
247203 }
248204
249- img := imagePush{Registry: registry, ImageName: imageName, Version: version}
205+ img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
250206 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
251207
252208 imageOps, err := newPushImageOps(inferredRemote)
@@ -262,22 +218,14 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
262218 return err
263219 }
264220 }
265- if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266- return err
267- }
268-
269- skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270221 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271- if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
222+ if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
272223 return err
273224 }
274225
275226 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276227 return err
277228 }
278- if !skipExplicitDeploy {
279- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280- }
281229 return nil
282230 }
283231
@@ -286,7 +234,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
286234 if err != nil {
287235 return "", nil, nil, err
288236 }
289- inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
237+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
290238 if err != nil {
291239 return "", nil, nil, err
292240 }
@@ -297,7 +245,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
297245 }
298246 return dockerfile, inferredRemote, handle, nil
299247 }
300- handle, err = resolveControlPlane(configPath)
248+ handle, err = resolveControlPlane(cliConfigPath)
301249 if err != nil {
302250 return "", nil, nil, err
303251 }
@@ -320,179 +268,15 @@ func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error
320268 return newImageOpsFn()
321269 }
322270
323-func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
271+func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
324272 if build.Enabled {
325- return buildAndPush(ctx, imageOps, build, img)
326- }
327- return tagAndPush(ctx, imageOps, img)
328-}
329-
330-// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
331-// should be skipped (either because --no-deploy was set, or the token lacks scope).
332-func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
333- // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
334- // Without this, the server's event listener would still auto-deploy the pushed image.
335- if err := plane.DeployIntent(ctx, imageName); err != nil {
336- if isInsufficientScope(err) {
337- fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
338- return true
339- }
340- // Non-fatal: worst case we get a redundant deploy via event
341- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
342- }
343- return noDeploy
344-}
345-
346-// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
347-// meaning the token lacks the required admin scope.
348-func isInsufficientScope(err error) bool {
349- var httpErr *remote.HTTPError
350- return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
351-}
352-
353-// resolveFromDomain resolves image info from a domain name (legacy mode).
354-func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
355- route, err := cp.GetRoute(ctx, pushDomain)
356- if err != nil {
357- return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
358- }
359-
360- registry, imageName, _ = parseImageRef(route.Image)
361- if registry == "" || imageName == "" {
362- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
363- }
364-
365- return registry, imageName, pushDomain, nil
366-}
367-
368-// resolveFromImage resolves domain(s) from an image name using the backend.
369-func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
370- // First, query the backend to find routes for this image
371- routes, err := cp.FindRoutesByImage(ctx, imageArg)
372- if err != nil {
373- return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
374- }
375-
376- routes = filterPreviewRoutes(routes)
377-
378- if len(routes) == 0 {
379- return "", "", "", noRouteForImageError(imageArg)
380- }
381-
382- // Pick the target domain
383- var targetRoute domain.Route
384- if len(routes) == 1 {
385- targetRoute = routes[0]
386- } else {
387- // Multiple domains: check Dockerfile labels first, then prompt
388- targetRoute, err = selectDomain(routes, dockerfile)
389- if err != nil {
390- return "", "", "", err
391- }
392- }
393-
394- registry, imageName, _ = parseImageRef(targetRoute.Image)
395- if registry == "" || imageName == "" {
396- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
397- }
398-
399- return registry, imageName, targetRoute.Domain, nil
400-}
401-
402-func isImageBootstrapError(err error) bool {
403- return errors.Is(err, domain.ErrNoRouteForImage)
404-}
405-
406-func noRouteForImageError(imageArg string) error {
407- // bootstrap command is registered in bootstrap.go (see issue #98)
408- return noRouteForImageBootstrapError{imageArg: imageArg}
409-}
410-
411-type noRouteForImageBootstrapError struct {
412- imageArg string
413-}
414-
415-func (e noRouteForImageBootstrapError) Error() string {
416- return fmt.Sprintf(
417- "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
418- e.imageArg,
419- e.imageArg,
420- e.imageArg,
421- e.imageArg,
422- )
423-}
424-
425-func (e noRouteForImageBootstrapError) Unwrap() error {
426- return domain.ErrNoRouteForImage
427-}
428-
429-// selectDomain picks the target domain from multiple routes.
430-// Checks Dockerfile labels first, then falls back to interactive selection.
431-func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
432- // Try to resolve from Dockerfile labels
433- labels := parseDockerfileLabels(dockerfile)
434- labelDomain := labels[domain.LabelDomain]
435- labelDomains := labels[domain.LabelDomains]
436-
437- // Collect domains from labels
438- var labelDomainList []string
439- if labelDomain != "" {
440- labelDomainList = append(labelDomainList, labelDomain)
441- }
442- if labelDomains != "" {
443- for d := range strings.SplitSeq(labelDomains, ",") {
444- d = strings.TrimSpace(d)
445- if d != "" {
446- labelDomainList = append(labelDomainList, d)
447- }
448- }
449- }
450-
451- // Try to find a matching route from labels
452- if len(labelDomainList) > 0 {
453- routeMap := make(map[string]domain.Route, len(routes))
454- for _, r := range routes {
455- routeMap[r.Domain] = r
456- }
457- for _, ld := range labelDomainList {
458- if r, ok := routeMap[ld]; ok {
459- return r, nil
460- }
461- }
462- }
463-
464- // Fall back to interactive selection
465- items := make([]string, 0, len(routes))
466- for _, r := range routes {
467- items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
468- }
469-
470- selected, err := components.RunSelector(
471- "Multiple domains found for this image. Select target:",
472- items,
473- "",
474- )
475- if err != nil {
476- return domain.Route{}, fmt.Errorf("selection error: %w", err)
273+ return buildAndPush(ctx, out, imageOps, build, img)
477274 }
478- if selected == "" {
479- return domain.Route{}, fmt.Errorf("no domain selected")
480- }
481-
482- // Extract domain from the selected display string
483- for i, item := range items {
484- if item == selected {
485- return routes[i], nil
486- }
487- }
488-
489- return domain.Route{}, fmt.Errorf("selected domain not found")
275+ return tagAndPush(ctx, out, imageOps, img)
490276 }
491277
492-// detectImageName auto-detects the image name from context.
493-// Resolution order:
494-// 1. Dockerfile label gordon.domain (if Dockerfile exists)
495-// 2. Current directory name
278+// detectImageName resolves the image name from Dockerfile labels or the
279+// current directory name.
496280 func detectImageName(dockerfile string) (string, error) {
497281 // Try Dockerfile labels
498282 labels := parseDockerfileLabels(dockerfile)
@@ -513,7 +297,7 @@ func detectImageName(dockerfile string) (string, error) {
513297
514298 dirName := filepath.Base(cwd)
515299 if dirName == "." || dirName == "/" {
516- return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
300+ return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
517301 }
518302
519303 return dirName, nil
@@ -655,7 +439,7 @@ func parseTagRef(ref string) string {
655439 return tag
656440 }
657441
658-func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
442+func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
659443 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
660444 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
661445 }
@@ -663,12 +447,16 @@ func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img
663447 // Build and load into local daemon (NOT --push).
664448 // The native registry push client handles chunked uploads to stay
665449 // within Cloudflare's 100MB per-request limit.
666- fmt.Println("\nBuilding image...")
450+ if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
451+ return err
452+ }
667453 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
668454 return err
669455 }
670456
671- fmt.Println("Pushing...")
457+ if err := cliWriteLine(out, "Pushing..."); err != nil {
458+ return err
459+ }
672460 if err := ops.Push(ctx, img.LatestRef); err != nil {
673461 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
674462 }
@@ -731,10 +519,15 @@ func buildImageArgs(ctx context.Context, version, platform, dockerfile string, b
731519 return args
732520 }
733521
734-func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
735- localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)
522+func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
523+ localImage := img.SourceRef
524+ if localImage == "" {
525+ return errors.New("push source image reference cannot be empty")
526+ }
736527
737- fmt.Println("\nChecking local image...")
528+ if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
529+ return err
530+ }
738531 exists, err := ops.Exists(ctx, localImage)
739532 if err != nil {
740533 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
@@ -743,7 +536,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
743536 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
744537 }
745538
746- fmt.Println("Tagging...")
539+ if err := cliWriteLine(out, "Tagging..."); err != nil {
540+ return err
541+ }
747542 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
748543 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
749544 }
@@ -753,7 +548,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
753548 }
754549 }
755550
756- fmt.Println("Pushing...")
551+ if err := cliWriteLine(out, "Pushing..."); err != nil {
552+ return err
553+ }
757554 if err := ops.Push(ctx, img.VersionRef); err != nil {
758555 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
759556 }
@@ -765,118 +562,6 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
765562 return nil
766563 }
767564
768-func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
769- if !noConfirm {
770- confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
771- if err != nil {
772- return err
773- }
774- if !confirmed {
775- return nil
776- }
777- }
778-
779- var (
780- result *remote.DeployResult
781- err error
782- )
783- if remoteCP, ok := cp.(*remoteControlPlane); ok {
784- result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
785- } else {
786- result, err = cp.Deploy(ctx, pushDomain)
787- }
788- if err != nil {
789- return formatDeployFailure(err)
790- }
791- containerID := shortContainerID(result.ContainerID)
792- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
793- return nil
794-}
795-
796-func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
797- if !isInteractiveTerminal() {
798- fmt.Printf("Deploying %s...\n", pushDomain)
799- return client.Deploy(ctx, pushDomain)
800- }
801-
802- done := make(chan deployOutcome, 1)
803- go func() {
804- result, err := client.Deploy(ctx, pushDomain)
805- done <- deployOutcome{result: result, err: err}
806- }()
807-
808- model := newDeploySpinnerModel(pushDomain, done)
809- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
810- fmt.Print("\r\033[K")
811- if err != nil {
812- return nil, err
813- }
814-
815- deployModel, ok := final.(deploySpinnerModel)
816- if !ok {
817- return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
818- }
819- if !deployModel.finished {
820- return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
821- }
822-
823- return deployModel.outcome.result, deployModel.outcome.err
824-}
825-
826-type deployOutcome struct {
827- result *remote.DeployResult
828- err error
829-}
830-
831-type deployDoneMsg deployOutcome
832-
833-type deploySpinnerModel struct {
834- spinner components.SpinnerModel
835- done <-chan deployOutcome
836- outcome deployOutcome
837- finished bool
838-}
839-
840-func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
841- return deploySpinnerModel{
842- spinner: components.NewSpinner(
843- components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
844- components.WithSpinnerType(components.SpinnerMiniDot),
845- ),
846- done: done,
847- }
848-}
849-
850-func (m deploySpinnerModel) Init() tea.Cmd {
851- return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
852-}
853-
854-func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
855- switch msg := msg.(type) {
856- case deployDoneMsg:
857- m.outcome = deployOutcome(msg)
858- m.finished = true
859- return m, tea.Quit
860- default:
861- updated, cmd := m.spinner.Update(msg)
862- spinnerModel, ok := updated.(components.SpinnerModel)
863- if ok {
864- m.spinner = spinnerModel
865- }
866- return m, cmd
867- }
868-}
869-
870-func (m deploySpinnerModel) View() string {
871- return m.spinner.View()
872-}
873-
874-func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
875- return func() tea.Msg {
876- return deployDoneMsg(<-done)
877- }
878-}
879-
880565 func isInteractiveTerminal() bool {
881566 term := os.Getenv("TERM")
882567 if term == "" || term == "dumb" {
@@ -898,6 +583,9 @@ func parseImageRef(image string) (registry, name, tag string) {
898583 }
899584 registry = parts[0]
900585 nameTag := parts[1]
586+ if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
587+ return registry, nameTag[:idx], nameTag[idx+1:]
588+ }
901589 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
902590 name = nameTag[:idx]
903591 tag = nameTag[idx+1:]
A internal/adapters/in/cli/push_behavior_test.go +61 -0
@@ -0,0 +1,61 @@
1+package cli
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type recordingPushOps struct {
12+ existsRef string
13+ tags [][2]string
14+ pushes []string
15+ exists bool
16+}
17+
18+func (o *recordingPushOps) Exists(_ context.Context, ref string) (bool, error) {
19+ o.existsRef = ref
20+ return o.exists, nil
21+}
22+
23+func (o *recordingPushOps) Tag(_ context.Context, source, target string) error {
24+ o.tags = append(o.tags, [2]string{source, target})
25+ return nil
26+}
27+
28+func (o *recordingPushOps) Push(_ context.Context, ref string) error {
29+ o.pushes = append(o.pushes, ref)
30+ return nil
31+}
32+
33+func (*recordingPushOps) Build(context.Context, []string) error { return nil }
34+
35+func TestTagAndPushPreservesExactSourceReference(t *testing.T) {
36+ ops := &recordingPushOps{exists: true}
37+ img := imagePush{
38+ SourceRef: "registry.example.com/team/app:v3",
39+ Version: "v4", VersionRef: "registry.example.com/team/app:v4",
40+ LatestRef: "registry.example.com/team/app:latest",
41+ }
42+
43+ require.NoError(t, tagAndPush(context.Background(), io.Discard, ops, img))
44+ require.Equal(t, img.SourceRef, ops.existsRef)
45+ require.Equal(t, [][2]string{
46+ {img.SourceRef, img.VersionRef},
47+ {img.SourceRef, img.LatestRef},
48+ }, ops.tags)
49+ require.Equal(t, []string{img.VersionRef, img.LatestRef}, ops.pushes)
50+}
51+
52+func TestTagAndPushRejectsMissingSourceReference(t *testing.T) {
53+ ops := &recordingPushOps{exists: true}
54+ err := tagAndPush(context.Background(), io.Discard, ops, imagePush{
55+ Registry: "registry.example.com", ImageName: "team/app", Version: "v4",
56+ })
57+ require.EqualError(t, err, "push source image reference cannot be empty")
58+ require.Empty(t, ops.existsRef)
59+ require.Empty(t, ops.tags)
60+ require.Empty(t, ops.pushes)
61+}
M internal/adapters/in/cli/push_image_ops.go +8 -2
@@ -35,6 +35,7 @@ type pushImageOps interface {
3535 type dockerImageOps struct {
3636 cli client.APIClient
3737 insecureTLS bool
38+ plainHTTP bool
3839 progress io.Writer
3940 remoteClient *remote.Client // nil when local
4041 }
@@ -70,8 +71,11 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
7071 if err != nil {
7172 return nil, err
7273 }
73- if resolved != nil && resolved.Token != "" {
74- ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
74+ if resolved != nil {
75+ ops.plainHTTP = strings.HasPrefix(strings.ToLower(resolved.URL), "http://")
76+ if resolved.Token != "" {
77+ ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
78+ }
7579 }
7680 return ops, nil
7781 }
@@ -87,6 +91,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
8791 opts := []registrypush.Option{
8892 registrypush.WithProgress(d.progress),
8993 registrypush.WithInsecureTLS(d.insecureTLS),
94+ registrypush.WithPlainHTTP(d.plainHTTP),
9095 }
9196
9297 if d.remoteClient != nil {
@@ -107,6 +112,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
107112 retryOpts := []registrypush.Option{
108113 registrypush.WithProgress(d.progress),
109114 registrypush.WithInsecureTLS(d.insecureTLS),
115+ registrypush.WithPlainHTTP(d.plainHTTP),
110116 registrypush.WithAuth(authHeader),
111117 }
112118 pusher = registrypush.New(retryOpts...)
M internal/adapters/in/cli/push_integration_test.go +8 -6
@@ -3,6 +3,7 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "net/http"
78 "net/http/httptest"
89 "strings"
@@ -33,9 +34,13 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
3334 img, err := random.Image(512, 1)
3435 require.NoError(t, err)
3536
37+ registry := strings.TrimPrefix(harness.server.URL, "http://")
38+ versionRef := registry + "/testapp:v1.0.0"
39+ latestRef := registry + "/testapp:latest"
40+
3641 ops := climocks.NewMockpushImageOps(t)
3742 ops.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
38- ops.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
43+ ops.On("Exists", mock.Anything, registry+"/testapp:v1.0.0").Return(true, nil)
3944 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {
4045 pusher := registrypush.New(
4146 registrypush.WithChunkSize(256),
@@ -47,13 +52,10 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
4752 return pusher.Push(ctx, ref)
4853 })
4954
50- registry := strings.TrimPrefix(harness.server.URL, "http://")
51- versionRef := registry + "/testapp:v1.0.0"
52- latestRef := registry + "/testapp:latest"
53-
54- err = tagAndPush(ctx, ops, imagePush{
55+ err = tagAndPush(ctx, io.Discard, ops, imagePush{
5556 Registry: registry,
5657 ImageName: "testapp",
58+ SourceRef: versionRef,
5759 Version: "v1.0.0",
5860 VersionRef: versionRef,
5961 LatestRef: latestRef,
M internal/adapters/in/cli/push_remote_inference_test.go +19 -414
@@ -13,16 +13,14 @@ import (
1313
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
16-
17- "github.com/bnema/gordon/internal/domain"
1816 )
1917
2018 func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
19+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
2220 assert.Equal(t, "myapp", image)
23- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
21+ return []string{"latest"}, http.StatusOK
2422 })
25- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
23+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
2624 assert.Equal(t, "myapp", image)
2725 return nil, http.StatusOK
2826 })
@@ -43,11 +41,11 @@ url = "`+staging.URL+`"
4341 }
4442
4543 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
44+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
45+ return []string{"latest"}, http.StatusOK
4846 })
49- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50- return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
47+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
48+ return []string{"latest"}, http.StatusOK
5149 })
5250
5351 configurePushRemoteInferenceTestEnv(t, `
@@ -73,9 +71,9 @@ func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
7371 })
7472
7573 called := false
76- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
74+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
7775 called = true
78- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
76+ return []string{"latest"}, http.StatusOK
7977 })
8078
8179 configurePushRemoteInferenceTestEnv(t, `
@@ -93,9 +91,9 @@ url = "`+prod.URL+`"
9391
9492 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
9593 called := false
96- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
94+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
9795 called = true
98- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
96+ return []string{"latest"}, http.StatusOK
9997 })
10098
10199 configurePushRemoteInferenceTestEnv(t, `
@@ -111,26 +109,11 @@ url = "`+prod.URL+`"
111109 assert.False(t, called)
112110 }
113111
114-func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116- return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117- })
118-
119- configurePushRemoteInferenceTestEnv(t, `
120-[remotes.prod]
121-url = "`+prod.URL+`"
122-`)
123-
124- resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125- require.NoError(t, err)
126- assert.Nil(t, resolved)
127-}
128-
129112 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
113+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
114+ return []string{"latest"}, http.StatusOK
132115 })
133- broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116+ broken := newTagsTestServer(t, func(image string) ([]string, int) {
134117 return nil, http.StatusInternalServerError
135118 })
136119
@@ -149,199 +132,6 @@ url = "`+broken.URL+`"
149132 assert.Contains(t, err.Error(), "broken")
150133 }
151134
152-func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155- })
156- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157- return nil, http.StatusNotFound
158- })
159-
160- configurePushRemoteInferenceTestEnv(t, `
161-[remotes.prod]
162-url = "`+prod.URL+`"
163-
164-[remotes.staging]
165-url = "`+staging.URL+`"
166-`)
167-
168- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169- require.NoError(t, err)
170- require.NotNil(t, resolved)
171- assert.Equal(t, "prod", resolved.Name)
172-}
173-
174-func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177- })
178- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180- })
181-
182- configurePushRemoteInferenceTestEnv(t, `
183-[remotes.prod]
184-url = "`+prod.URL+`"
185-
186-[remotes.staging]
187-url = "`+staging.URL+`"
188-`)
189-
190- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191- require.Error(t, err)
192- assert.Nil(t, resolved)
193- assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194-}
195-
196-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198- getRoute: func(domainName string) (*domain.Route, int) {
199- return nil, http.StatusNotFound
200- },
201- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202- return &domain.CleanupReport{
203- Domain: domainName,
204- OrphanedEntities: []domain.CleanupOrphanedEntity{{
205- Kind: "route_container",
206- ID: "abc123",
207- Status: "running",
208- }},
209- }, http.StatusOK
210- },
211- })
212- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213- getRoute: func(domainName string) (*domain.Route, int) {
214- return nil, http.StatusNotFound
215- },
216- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218- },
219- })
220-
221- configurePushRemoteInferenceTestEnv(t, `
222-[remotes.prod]
223-url = "`+prod.URL+`"
224-
225-[remotes.staging]
226-url = "`+staging.URL+`"
227-`)
228-
229- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230- require.NoError(t, err)
231- require.NotNil(t, resolved)
232- assert.Equal(t, "prod", resolved.Name)
233-}
234-
235-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237- getRoute: func(domainName string) (*domain.Route, int) {
238- return nil, http.StatusNotFound
239- },
240- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241- return &domain.CleanupReport{
242- Domain: domainName,
243- PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244- }, http.StatusOK
245- },
246- })
247- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248- getRoute: func(domainName string) (*domain.Route, int) {
249- return nil, http.StatusNotFound
250- },
251- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253- },
254- })
255-
256- configurePushRemoteInferenceTestEnv(t, `
257-[remotes.prod]
258-url = "`+prod.URL+`"
259-
260-[remotes.staging]
261-url = "`+staging.URL+`"
262-`)
263-
264- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265- require.NoError(t, err)
266- require.NotNil(t, resolved)
267- assert.Equal(t, "prod", resolved.Name)
268-}
269-
270-func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272- getRoute: func(domainName string) (*domain.Route, int) {
273- return nil, http.StatusNotFound
274- },
275- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276- return nil, http.StatusForbidden
277- },
278- })
279-
280- configurePushRemoteInferenceTestEnv(t, `
281-[remotes.prod]
282-url = "`+prod.URL+`"
283-`)
284-
285- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286- require.Error(t, err)
287- assert.Nil(t, resolved)
288- assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289-}
290-
291-func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
292- prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
293- return []string{"app.example.com"}, http.StatusOK
294- })
295- staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
296- return nil, http.StatusOK
297- })
298-
299- configurePushRemoteInferenceTestEnv(t, `
300-[remotes.prod]
301-url = "`+prod.URL+`"
302-
303-[remotes.staging]
304-url = "`+staging.URL+`"
305-`)
306-
307- resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
308- require.NoError(t, err)
309- require.NotNil(t, resolved)
310- assert.Equal(t, "prod", resolved.Name)
311-}
312-
313-func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
314- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
315- getRoute: func(domainName string) (*domain.Route, int) {
316- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
317- },
318- getAttachmentsConfig: func(target string) ([]string, int) {
319- return nil, http.StatusNotFound
320- },
321- })
322- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
323- getRoute: func(domainName string) (*domain.Route, int) {
324- return nil, http.StatusNotFound
325- },
326- getAttachmentsConfig: func(target string) ([]string, int) {
327- return nil, http.StatusNotFound
328- },
329- })
330-
331- configurePushRemoteInferenceTestEnv(t, `
332-[remotes.prod]
333-url = "`+prod.URL+`"
334-
335-[remotes.staging]
336-url = "`+staging.URL+`"
337-`)
338-
339- resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
340- require.NoError(t, err)
341- require.NotNil(t, resolved)
342- assert.Equal(t, "prod", resolved.Name)
343-}
344-
345135 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346136 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347137 return []string{"latest", "v1.0.0"}, http.StatusOK
@@ -386,205 +176,20 @@ func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
386176
387177 configHome := t.TempDir()
388178 t.Setenv("XDG_CONFIG_HOME", configHome)
389- configPath := filepath.Join(configHome, "gordon", "remotes.toml")
390- require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
391- require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
179+ cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml")
180+ require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755))
181+ require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392182 }
393183
394-func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
395- t.Helper()
396-
397- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398- if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
399- http.NotFound(w, r)
400- return
401- }
402-
403- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
404- if !assert.NoError(t, err) {
405- return
406- }
407-
408- routes, status := handler(image)
409- if status >= http.StatusBadRequest {
410- w.Header().Set("Content-Type", "application/json")
411- w.WriteHeader(status)
412- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
413- return
414- }
415- return
416- }
417-
418- w.Header().Set("Content-Type", "application/json")
419- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
420- "image": image,
421- "routes": routes,
422- })) {
423- return
424- }
425- }))
426- t.Cleanup(server.Close)
427- return server
428-}
429-
430-func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
431- t.Helper()
432- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
433-}
434-
435-type multiAdminProbeHandlers struct {
436- getRoute func(domainName string) (*domain.Route, int)
437- getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
438- getAttachmentsConfig func(target string) ([]string, int)
439- listTags func(repository string) ([]string, int)
440- findAttachmentTarget func(image string) ([]string, int)
441-}
442-
443-func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
184+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
444185 t.Helper()
445-
446186 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447- switch {
448- case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449- handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
450- case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
451- handleGetRouteProbe(t, w, r, handlers.getRoute)
452- case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
453- handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
454- case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
455- handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
456- case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
457- handleListTagsProbe(t, w, r, handlers.listTags)
458- default:
459- http.NotFound(w, r)
460- }
187+ handleListTagsProbe(t, w, r, handler)
461188 }))
462189 t.Cleanup(server.Close)
463190 return server
464191 }
465192
466-func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
467- t.Helper()
468- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
469-}
470-
471-func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
472- t.Helper()
473- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
474-}
475-
476-func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
477- t.Helper()
478- if handler == nil {
479- http.NotFound(w, r)
480- return
481- }
482- domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
483- if !assert.NoError(t, err) {
484- return
485- }
486- route, status := handler(domainName)
487- if status >= http.StatusBadRequest {
488- w.Header().Set("Content-Type", "application/json")
489- w.WriteHeader(status)
490- if status == http.StatusNotFound {
491- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
492- return
493- }
494- return
495- }
496- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
497- return
498- }
499- return
500- }
501- w.Header().Set("Content-Type", "application/json")
502- if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
503- return
504- }
505-}
506-
507-func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508- t.Helper()
509- if handler == nil {
510- http.NotFound(w, r)
511- return
512- }
513- domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514- if !assert.NoError(t, err) {
515- return
516- }
517- report, status := handler(domainName)
518- if status >= http.StatusBadRequest {
519- w.Header().Set("Content-Type", "application/json")
520- w.WriteHeader(status)
521- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522- return
523- }
524- return
525- }
526- w.Header().Set("Content-Type", "application/json")
527- if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528- return
529- }
530-}
531-
532-func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
533- t.Helper()
534- if handler == nil {
535- http.NotFound(w, r)
536- return
537- }
538- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
539- if !assert.NoError(t, err) {
540- return
541- }
542- targets, status := handler(image)
543- if status >= http.StatusBadRequest {
544- w.Header().Set("Content-Type", "application/json")
545- w.WriteHeader(status)
546- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
547- return
548- }
549- return
550- }
551- w.Header().Set("Content-Type", "application/json")
552- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
553- return
554- }
555-}
556-
557-func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
558- t.Helper()
559- if handler == nil {
560- http.NotFound(w, r)
561- return
562- }
563- target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
564- if !assert.NoError(t, err) {
565- return
566- }
567- images, status := handler(target)
568- if status >= http.StatusBadRequest {
569- w.Header().Set("Content-Type", "application/json")
570- w.WriteHeader(status)
571- if status == http.StatusNotFound {
572- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
573- return
574- }
575- return
576- }
577- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
578- return
579- }
580- return
581- }
582- w.Header().Set("Content-Type", "application/json")
583- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
584- return
585- }
586-}
587-
588193 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589194 t.Helper()
590195 if handler == nil {
M internal/adapters/in/cli/push_test.go +5 -110
@@ -2,8 +2,6 @@ package cli
22
33 import (
44 "context"
5- "errors"
6- "fmt"
75 "io"
86 "os"
97 "os/exec"
@@ -11,11 +9,7 @@ import (
119 "strings"
1210 "testing"
1311
14- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
15- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16- "github.com/bnema/gordon/internal/domain"
1712 "github.com/stretchr/testify/assert"
18- "github.com/stretchr/testify/mock"
1913 )
2014
2115 func TestValidateBuildArg(t *testing.T) {
@@ -79,12 +73,12 @@ func TestClassifyPushArgument(t *testing.T) {
7973 arg string
8074 want classifiedPushArg
8175 }{
82- {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
83- {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
84- {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp"}},
85- {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp@sha256:deadbeef"}},
76+ {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:latest", repository: "myapp"}},
77+ {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:v1.2.3", repository: "myapp"}},
78+ {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp:v1.2.3", repository: "myapp"}},
79+ {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp@sha256:deadbeef", repository: "myapp"}},
8680 {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},
87- {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
81+ {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp", repository: "myapp"}},
8882 }
8983
9084 for _, tt := range tests {
@@ -449,102 +443,3 @@ func TestParseLabelPair(t *testing.T) {
449443 })
450444 }
451445 }
452-
453-func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
454- cpMock := climocks.NewMockControlPlane(t)
455- cpMock.EXPECT().FindRoutesByImage(context.Background(), "myapp").Return(nil, nil).Once()
456-
457- _, _, _, err := resolveFromImage(context.Background(), cpMock, "myapp", "Dockerfile")
458-
459- assert.Error(t, err)
460- assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
461- assert.Contains(t, err.Error(), "gordon bootstrap")
462-}
463-
464-func TestResolveRoute_DottedBareImageUsesImageLookup(t *testing.T) {
465- cpMock := climocks.NewMockControlPlane(t)
466- cpMock.EXPECT().FindRoutesByImage(context.Background(), "my.app").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/my.app:latest"}}, nil).Once()
467-
468- registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "my.app", "", "Dockerfile")
469-
470- assert.NoError(t, err)
471- assert.Equal(t, "registry.example.com", registry)
472- assert.Equal(t, "my.app", imageName)
473- assert.Equal(t, "app.example.com", pushDomain)
474-}
475-
476-func TestResolveRoute_DottedBareImageNoRoutesKeepsBootstrapError(t *testing.T) {
477- cpMock := climocks.NewMockControlPlane(t)
478- imageLookup := cpMock.EXPECT().FindRoutesByImage(context.Background(), "my.app").Return(nil, nil).Once()
479- routeLookup := cpMock.EXPECT().GetRoute(context.Background(), "my.app").Return(nil, domain.ErrRouteNotFound).Once()
480- mock.InOrder(imageLookup, routeLookup)
481-
482- _, _, _, err := resolveRoute(context.Background(), cpMock, "my.app", "", "Dockerfile")
483-
484- assert.Error(t, err)
485- assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
486- assert.Contains(t, err.Error(), `no route configured for image "my.app"`)
487- assert.Contains(t, err.Error(), "gordon bootstrap")
488- assert.NotContains(t, err.Error(), "failed to get route for domain")
489-}
490-
491-func TestNoRouteForImageErrorWrapsSentinel(t *testing.T) {
492- err := noRouteForImageError("myapp")
493-
494- assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
495- assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
496-}
497-
498-func TestResolveRoute_DottedBareDomainFallsBackToLegacyLookup(t *testing.T) {
499- cpMock := climocks.NewMockControlPlane(t)
500- imageLookup := cpMock.EXPECT().FindRoutesByImage(context.Background(), "app.example.com").Return(nil, nil).Once()
501- routeLookup := cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}, nil).Once()
502- mock.InOrder(imageLookup, routeLookup)
503-
504- registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "app.example.com", "", "Dockerfile")
505-
506- assert.NoError(t, err)
507- assert.Equal(t, "registry.example.com", registry)
508- assert.Equal(t, "myapp", imageName)
509- assert.Equal(t, "app.example.com", pushDomain)
510-}
511-
512-func TestResolveRoute_TaggedImageStripsTagBeforeLookup(t *testing.T) {
513- cpMock := climocks.NewMockControlPlane(t)
514- cpMock.EXPECT().FindRoutesByImage(context.Background(), "myapp").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil).Once()
515-
516- registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "myapp:v1.2.3", "", "Dockerfile")
517-
518- assert.NoError(t, err)
519- assert.Equal(t, "registry.example.com", registry)
520- assert.Equal(t, "myapp", imageName)
521- assert.Equal(t, "app.example.com", pushDomain)
522-}
523-
524-func TestResolveRoute_RegistryQualifiedTaggedImageUsesImageLookup(t *testing.T) {
525- cpMock := climocks.NewMockControlPlane(t)
526- cpMock.EXPECT().FindRoutesByImage(context.Background(), "registry.example.com/myapp").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil).Once()
527-
528- _, _, _, err := resolveRoute(context.Background(), cpMock, "registry.example.com/myapp:v1.2.3", "", "Dockerfile")
529-
530- assert.NoError(t, err)
531-}
532-
533-func TestIsInsufficientScope(t *testing.T) {
534- tests := []struct {
535- name string
536- err error
537- want bool
538- }{
539- {"nil error", nil, false},
540- {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true},
541- {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true},
542- {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false},
543- {"plain error", fmt.Errorf("connection refused"), false},
544- }
545- for _, tt := range tests {
546- t.Run(tt.name, func(t *testing.T) {
547- assert.Equal(t, tt.want, isInsufficientScope(tt.err))
548- })
549- }
550-}
A internal/adapters/in/cli/remote/apps.go +279 -0
@@ -0,0 +1,279 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/hex"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/url"
12+
13+ "github.com/google/uuid"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+)
17+
18+// Apps API: daemon-owned app mutations through the existing V2
19+// administration transport. Every mutation carries an Idempotency-Key
20+// header (client-generated ULID); the daemon persists the claim before
21+// effects and replays recorded results. On ambiguous transport outcome
22+// the caller gets OutcomeUnknownError and must re-query by key — never
23+// blindly retry.
24+
25+// newIdempotencyKey allocates a time-ordered key (uuid v7).
26+func newIdempotencyKey() string {
27+ id, err := uuid.NewV7()
28+ if err != nil {
29+ id = uuid.New()
30+ }
31+ var buf [32]byte
32+ hex.Encode(buf[:], id[:])
33+ return string(buf[:])
34+}
35+
36+// mutationPost sends a POST mutation with an idempotency key.
37+func (c *Client) mutationPost(ctx context.Context, path string, key string, body any) (*http.Response, error) {
38+ var jsonBody []byte
39+ if body != nil {
40+ var err error
41+ jsonBody, err = json.Marshal(body)
42+ if err != nil {
43+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
44+ }
45+ }
46+ return c.doMutation(ctx, http.MethodPost, path, key, jsonBody)
47+}
48+
49+// doMutation executes one mutation request with the idempotency header.
50+// Transport ambiguity surfaces as OutcomeUnknownError: the caller must
51+// re-query GET …/operations/by-key/{key} before any retry, and retries
52+// reuse the SAME key.
53+func (c *Client) doMutation(ctx context.Context, method, path, key string, jsonBody []byte) (*http.Response, error) {
54+ reqURL := c.baseURL + "/admin" + path
55+
56+ var bodyReader io.Reader
57+ if jsonBody != nil {
58+ bodyReader = bytes.NewReader(jsonBody)
59+ }
60+ req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
61+ if err != nil {
62+ return nil, fmt.Errorf("failed to create request: %w", err)
63+ }
64+ req.Header.Set("Content-Type", "application/json")
65+ req.Header.Set("Accept", "application/json")
66+ req.Header.Set("Idempotency-Key", key)
67+
68+ bearer, err := c.bearerToken(ctx)
69+ if err != nil {
70+ return nil, err
71+ }
72+ if bearer != "" {
73+ req.Header.Set("Authorization", "Bearer "+bearer)
74+ }
75+
76+ resp, err := c.httpClient.Do(req)
77+ if err != nil {
78+ return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err}
79+ }
80+ if isRetryableStatus(resp.StatusCode) {
81+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
82+ _ = resp.Body.Close()
83+ return nil, &OutcomeUnknownError{
84+ Method: method,
85+ Path: path,
86+ Err: parseErrorResponse(resp, body),
87+ }
88+ }
89+ return resp, nil
90+}
91+
92+// ApplyApp validates and persists desired state (or dry-runs).
93+func (c *Client) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
94+ resp, err := c.mutationPost(ctx, "/apps/apply", newIdempotencyKey(), req)
95+ if err != nil {
96+ return nil, err
97+ }
98+ var result dto.AppApplyResponse
99+ if err := parseResponse(resp, &result); err != nil {
100+ return nil, fmt.Errorf("apply app: %w", err)
101+ }
102+ return &result, nil
103+}
104+
105+// ListApps returns desired+active summaries.
106+func (c *Client) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
107+ resp, err := c.request(ctx, http.MethodGet, "/apps", nil)
108+ if err != nil {
109+ return nil, err
110+ }
111+ var envelope struct {
112+ Apps []dto.AppSummaryDTO `json:"apps"`
113+ }
114+ if err := parseResponse(resp, &envelope); err != nil {
115+ return nil, fmt.Errorf("list apps: %w", err)
116+ }
117+ return envelope.Apps, nil
118+}
119+
120+// ShowApp inspects desired + active + intent + op ref.
121+func (c *Client) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
122+ resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app), nil)
123+ if err != nil {
124+ return nil, err
125+ }
126+ var result dto.AppShowResponse
127+ if err := parseResponse(resp, &result); err != nil {
128+ return nil, fmt.Errorf("show app %s: %w", app, err)
129+ }
130+ return &result, nil
131+}
132+
133+// DiffApp returns the normalized desired-vs-active diff.
134+func (c *Client) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
135+ resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/diff", nil)
136+ if err != nil {
137+ return nil, err
138+ }
139+ var result dto.AppDiffResponse
140+ if err := parseResponse(resp, &result); err != nil {
141+ return nil, fmt.Errorf("diff app %s: %w", app, err)
142+ }
143+ return &result, nil
144+}
145+
146+// DeployApp activates a captured revision. It returns the key used so
147+// ambiguous outcomes can be recovered via OperationByKey.
148+func (c *Client) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
149+ key := newIdempotencyKey()
150+ resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/deploy", key, req)
151+ if err != nil {
152+ return nil, key, err
153+ }
154+ result, err := parseDeployResponse(resp, "deploy", app)
155+ return result, key, err
156+}
157+
158+// StopApp persists durable stopped intent and stops exact containers.
159+func (c *Client) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
160+ return c.lifecyclePost(ctx, app, "stop")
161+}
162+
163+// StartApp clears stopped intent and ensures running from active.
164+func (c *Client) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
165+ return c.lifecyclePost(ctx, app, "start")
166+}
167+
168+// RestartApp restarts from pinned digests (no re-resolution).
169+func (c *Client) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {
170+ key := newIdempotencyKey()
171+ path := "/apps/" + url.PathEscape(app) + "/restart"
172+ if service != "" {
173+ path += "?service=" + url.QueryEscape(service)
174+ }
175+ resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil)
176+ if err != nil {
177+ return nil, key, err
178+ }
179+ result, err := parseDeployResponse(resp, "restart", app)
180+ return result, key, err
181+}
182+
183+// RemoveApp withdraws workloads; volumes and secrets are retained.
184+func (c *Client) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
185+ return c.lifecyclePost(ctx, app, "remove")
186+}
187+
188+// lifecyclePost posts a body-less lifecycle mutation.
189+func (c *Client) lifecyclePost(ctx context.Context, app, action string) (*dto.AppDeployResponse, string, error) {
190+ key := newIdempotencyKey()
191+ resp, err := c.doMutation(ctx, http.MethodPost, "/apps/"+url.PathEscape(app)+"/"+action, key, nil)
192+ if err != nil {
193+ return nil, key, err
194+ }
195+ result, err := parseDeployResponse(resp, action, app)
196+ return result, key, err
197+}
198+
199+// maxDeployConflictBodySize bounds 409 journal reads: journals carry
200+// per-service results and steps and can exceed the 1 KB error cap.
201+const maxDeployConflictBodySize int64 = 1 << 20
202+
203+// AppOpConflictError reports a 409 deploy/lifecycle outcome while carrying
204+// the decoded operation journal. Use errors.As to render the journal and
205+// retain failure semantics; the idempotency key travels alongside the
206+// returned error from DeployApp/RestartApp/lifecycle calls.
207+type AppOpConflictError struct {
208+ StatusCode int
209+ Status string
210+ Response dto.AppDeployResponse
211+}
212+
213+func (e *AppOpConflictError) Error() string {
214+ return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome)
215+}
216+
217+// parseDeployResponse decodes a deploy/lifecycle mutation response. A 409
218+// carries the journaled AppDeployResponse body (per-service results,
219+// steps, effective/observed state) instead of an error envelope; it is
220+// decoded and surfaced as AppOpConflictError so the CLI can render the
221+// journal while retaining failure semantics.
222+func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
223+ if resp.StatusCode == http.StatusConflict {
224+ return decodeDeployConflict(resp, action, app)
225+ }
226+ var result dto.AppDeployResponse
227+ if err := parseResponse(resp, &result); err != nil {
228+ return nil, fmt.Errorf("%s app %s: %w", action, app, err)
229+ }
230+ return &result, nil
231+}
232+
233+// decodeDeployConflict decodes a 409 journal body into AppDeployResponse.
234+// Bodies that are not journals (preflight AppError envelopes) fall back to
235+// HTTPError so generic conflicts keep their existing shape.
236+func decodeDeployConflict(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
237+ defer resp.Body.Close()
238+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxDeployConflictBodySize))
239+ var result dto.AppDeployResponse
240+ if err := json.Unmarshal(body, &result); err != nil {
241+ return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body))
242+ }
243+ if result.Op == "" && result.Outcome == "" && len(result.Services) == 0 && len(result.Steps) == 0 {
244+ return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body))
245+ }
246+ conflict := &AppOpConflictError{StatusCode: resp.StatusCode, Status: resp.Status, Response: result}
247+ return &result, fmt.Errorf("%s app %s: %w", action, app, conflict)
248+}
249+
250+// OperationByKey recovers an ambiguous mutation outcome by idempotency key.
251+func (c *Client) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) {
252+ resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/operations/by-key/"+url.PathEscape(key), nil)
253+ if err != nil {
254+ return nil, err
255+ }
256+ var result dto.AppDeployResponse
257+ if err := parseResponse(resp, &result); err != nil {
258+ return nil, fmt.Errorf("operation by key: %w", err)
259+ }
260+ return &result, nil
261+}
262+
263+// SetAppSecrets writes secret values for pre-registered names.
264+func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
265+ resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/set", newIdempotencyKey(), req)
266+ if err != nil {
267+ return err
268+ }
269+ return parseResponse(resp, nil)
270+}
271+
272+// DeleteAppSecret removes one secret value (refused when referenced).
273+func (c *Client) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error {
274+ resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/delete", newIdempotencyKey(), req)
275+ if err != nil {
276+ return err
277+ }
278+ return parseResponse(resp, nil)
279+}
A internal/adapters/in/cli/remote/apps_test.go +173 -0
@@ -0,0 +1,173 @@
1+package remote
2+
3+// Tests for the v2.50 app mutation transport (apps.go): every mutation
4+// carries a client-generated Idempotency-Key, retryable gateway statuses
5+// surface OutcomeUnknownError without replaying the mutation, and
6+// ambiguous outcomes recover via the by-key endpoint.
7+
8+import (
9+ "context"
10+ "encoding/json"
11+ "net/http"
12+ "net/http/httptest"
13+ "sync/atomic"
14+ "testing"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ "github.com/bnema/gordon/internal/adapters/dto"
20+)
21+
22+func TestClientApplyApp_SendsIdempotencyKey(t *testing.T) {
23+ var gotKey string
24+ var gotBody dto.AppApplyRequest
25+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+ require.Equal(t, "/admin/apps/apply", r.URL.Path)
27+ require.Equal(t, http.MethodPost, r.Method)
28+ gotKey = r.Header.Get("Idempotency-Key")
29+ require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
30+ w.Header().Set("Content-Type", "application/json")
31+ _ = json.NewEncoder(w).Encode(dto.AppApplyResponse{
32+ App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1",
33+ })
34+ }))
35+ defer srv.Close()
36+
37+ resp, err := NewClient(srv.URL).ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "x"})
38+ require.NoError(t, err)
39+ assert.Equal(t, "blog", resp.App)
40+ assert.NotEmpty(t, gotKey, "mutations must carry an Idempotency-Key")
41+ assert.Equal(t, "x", gotBody.ManifestTOML)
42+}
43+
44+func TestClientDeployApp_GatewayErrorIsOutcomeUnknownWithoutReplay(t *testing.T) {
45+ var mutations int32
46+ var gotKey string
47+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+ require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
49+ atomic.AddInt32(&mutations, 1)
50+ gotKey = r.Header.Get("Idempotency-Key")
51+ w.WriteHeader(http.StatusServiceUnavailable)
52+ _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
53+ }))
54+ defer srv.Close()
55+
56+ resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
57+ require.Nil(t, resp)
58+ require.Error(t, err)
59+ var unknown *OutcomeUnknownError
60+ require.ErrorAs(t, err, &unknown)
61+ assert.EqualValues(t, 1, atomic.LoadInt32(&mutations), "mutations must never replay on ambiguity")
62+ assert.NotEmpty(t, key)
63+ assert.Equal(t, gotKey, key, "recovery re-queries with the SAME key")
64+}
65+
66+func TestClientOperationByKey_RecoveryPath(t *testing.T) {
67+ var gotPath string
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ gotPath = r.URL.EscapedPath()
70+ require.Equal(t, http.MethodGet, r.Method)
71+ w.Header().Set("Content-Type", "application/json")
72+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
73+ Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success",
74+ })
75+ }))
76+ defer srv.Close()
77+
78+ resp, err := NewClient(srv.URL).OperationByKey(context.Background(), "blog", "key-abc")
79+ require.NoError(t, err)
80+ assert.Equal(t, "op-1", resp.Op)
81+ assert.Equal(t, "/admin/apps/blog/operations/by-key/key-abc", gotPath)
82+}
83+
84+func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
85+ var keys []string
86+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+ keys = append(keys, r.Header.Get("Idempotency-Key"))
88+ w.Header().Set("Content-Type", "application/json")
89+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
90+ }))
91+ defer srv.Close()
92+
93+ client := NewClient(srv.URL)
94+ ctx := context.Background()
95+ _, _, err := client.StopApp(ctx, "blog")
96+ require.NoError(t, err)
97+ _, _, err = client.StartApp(ctx, "blog")
98+ require.NoError(t, err)
99+ _, _, err = client.RemoveApp(ctx, "blog")
100+ require.NoError(t, err)
101+ require.Len(t, keys, 3)
102+ for _, key := range keys {
103+ assert.NotEmpty(t, key)
104+ }
105+ assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106+}
107+
108+func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {
109+ journal := dto.AppDeployResponse{
110+ Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
111+ Services: map[string]dto.AppServiceResultDTO{
112+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
113+ },
114+ Steps: []dto.AppStepDTO{{ID: "service.web.start", State: "failed", Error: "nope"}},
115+ }
116+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+ require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
118+ w.Header().Set("Content-Type", "application/json")
119+ w.WriteHeader(http.StatusConflict)
120+ _ = json.NewEncoder(w).Encode(journal)
121+ }))
122+ defer srv.Close()
123+
124+ resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
125+ require.Error(t, err)
126+ require.NotNil(t, resp, "conflict must preserve the decoded journal")
127+ assert.Equal(t, "op-9", resp.Op)
128+ assert.Equal(t, "failed", resp.Services["web"].Result)
129+ assert.NotEmpty(t, key)
130+ var conflict *AppOpConflictError
131+ require.ErrorAs(t, err, &conflict)
132+ assert.Equal(t, http.StatusConflict, conflict.StatusCode)
133+ assert.Equal(t, "op-9", conflict.Response.Op)
134+ assert.Len(t, conflict.Response.Steps, 1)
135+}
136+
137+func TestClientStopApp_ConflictDecodesJournal(t *testing.T) {
138+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+ require.Equal(t, "/admin/apps/blog/stop", r.URL.Path)
140+ w.Header().Set("Content-Type", "application/json")
141+ w.WriteHeader(http.StatusConflict)
142+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
143+ Op: "op-7", App: "blog", Outcome: "partial",
144+ Services: map[string]dto.AppServiceResultDTO{
145+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
146+ },
147+ })
148+ }))
149+ defer srv.Close()
150+
151+ resp, key, err := NewClient(srv.URL).StopApp(context.Background(), "blog")
152+ require.Error(t, err)
153+ require.NotNil(t, resp)
154+ assert.Equal(t, "partial", resp.Outcome)
155+ assert.NotEmpty(t, key)
156+ var conflict *AppOpConflictError
157+ require.ErrorAs(t, err, &conflict)
158+}
159+
160+func TestClientDeployApp_ConflictWithoutJournalFallsBackToHTTPError(t *testing.T) {
161+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162+ w.WriteHeader(http.StatusConflict)
163+ _, _ = w.Write([]byte(`{"error":"state-conflict","message":"busy"}`))
164+ }))
165+ defer srv.Close()
166+
167+ resp, _, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
168+ require.Error(t, err)
169+ require.Nil(t, resp)
170+ var httpErr *HTTPError
171+ require.ErrorAs(t, err, &httpErr)
172+ assert.Equal(t, http.StatusConflict, httpErr.StatusCode)
173+}
M internal/adapters/in/cli/remote/client.go +47 -498
@@ -478,210 +478,16 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body
478478 return nil, fmt.Errorf("request failed after retries")
479479 }
480480
481-// Routes API
482-
483-// Type aliases for API types using shared DTO package.
484-type RouteInfo = dto.RouteInfo
485-type Attachment = dto.Attachment
486-
487-// ListRoutes returns all configured routes.
488-func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
489- resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
490- if err != nil {
491- return nil, err
492- }
493-
494- var result struct {
495- Routes []domain.Route `json:"routes"`
496- }
497- if err := parseResponse(resp, &result); err != nil {
498- return nil, err
499- }
500-
501- return result.Routes, nil
502-}
503-
504-// ListRoutesWithDetails returns routes with network and attachment info.
505-func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
506- resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
507- if err != nil {
508- return nil, err
509- }
510-
511- var result struct {
512- Routes []RouteInfo `json:"routes"`
513- }
514- if err := parseResponse(resp, &result); err != nil {
515- return nil, err
516- }
517-
518- return result.Routes, nil
519-}
520-
521-// ListNetworks returns Gordon-managed networks.
522-func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
523- resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
524- if err != nil {
525- return nil, err
526- }
527-
528- var result struct {
529- Networks []*domain.NetworkInfo `json:"networks"`
530- }
531- if err := parseResponse(resp, &result); err != nil {
532- return nil, err
533- }
534-
535- return result.Networks, nil
536-}
537-
538-// ListAttachments returns attachments for a domain.
539-func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
540- if routeDomain == "" {
541- return nil, fmt.Errorf("domain is required")
542- }
543- path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
544- resp, err := c.request(ctx, http.MethodGet, path, nil)
545- if err != nil {
546- return nil, err
547- }
548-
549- var result struct {
550- Attachments []Attachment `json:"attachments"`
551- }
552- if err := parseResponse(resp, &result); err != nil {
553- return nil, err
554- }
555-
556- return result.Attachments, nil
557-}
558-
559-// GetRoute returns a specific route by domain.
560-func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
561- resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
562- if err != nil {
563- return nil, err
564- }
565-
566- var route domain.Route
567- if err := parseResponse(resp, &route); err != nil {
568- var httpErr *HTTPError
569- if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
570- return nil, domain.ErrRouteNotFound
571- }
572- return nil, err
573- }
574-
575- return &route, nil
576-}
577-
578-// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
579-func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
580- resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
581- if err != nil {
582- return nil, err
583- }
584-
585- var report domain.CleanupReport
586- if err := parseResponse(resp, &report); err != nil {
587- return nil, err
588- }
589-
590- return &report, nil
591-}
592-
593-// FindRoutesByImage returns all routes associated with the given image name.
594-func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
595- resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
596- if err != nil {
597- return nil, err
598- }
599-
600- var result struct {
601- Image string `json:"image"`
602- Routes []domain.Route `json:"routes"`
603- }
604- if err := parseResponse(resp, &result); err != nil {
605- return nil, err
606- }
607-
608- return result.Routes, nil
609-}
610-
611-// AddRoute adds a new route.
612-func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
613- resp, err := c.request(ctx, http.MethodPost, "/routes", route)
614- if err != nil {
615- return err
616- }
617- return parseResponse(resp, nil)
618-}
619-
620-// UpdateRoute updates an existing route.
621-func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
622- resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
623- if err != nil {
624- return err
625- }
626- return parseResponse(resp, nil)
627-}
628-
629-// RemoveRoute removes a route by domain.
630-func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
631- _, err := c.RemoveRouteWithCleanup(ctx, routeDomain)
632- return err
633-}
634-
635-// RemoveRouteWithCleanup removes a route and returns the server cleanup report.
636-func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
637- resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
638- if err != nil {
639- return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
640- }
641- var result dto.RouteDeleteResponse
642- if err := parseResponse(resp, &result); err != nil {
643- return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
644- }
645- return &result, nil
646-}
647-
648-func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
649- resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
650- if err != nil {
651- return nil, err
652- }
653- var result dto.BootstrapResponse
654- if err := parseResponse(resp, &result); err != nil {
655- return nil, err
656- }
657- return &result, nil
658-}
659-
660481 // Secrets API
661482
662-// AttachmentSecrets represents secrets for an attachment container.
663-type AttachmentSecrets struct {
664- Service string `json:"service"`
665- Keys []string `json:"keys"`
666-}
667-
668-// SecretsListResult contains domain secrets and any attachment secrets.
483+// SecretsListResult contains domain secret keys.
669484 type SecretsListResult struct {
670- Domain string `json:"domain"`
671- Keys []string `json:"keys"`
672- Attachments []AttachmentSecrets `json:"attachments,omitempty"`
485+ Domain string `json:"domain"`
486+ Keys []string `json:"keys"`
673487 }
674488
675-// ListSecrets returns the list of secret keys for a domain.
676-func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
677- result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
678- if err != nil {
679- return nil, err
680- }
681- return result.Keys, nil
682-}
683-
684-// ListSecretsWithAttachments returns domain secrets and attachment secrets.
489+// ListSecretsWithAttachments returns domain secret keys. Attachment
490+// containers are retired: the daemon answers without attachment keys.
685491 func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
686492 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
687493 if err != nil {
@@ -714,40 +520,18 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err
714520 return parseResponse(resp, nil)
715521 }
716522
717-// SetAttachmentSecrets sets secrets for an attachment container.
718-func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
719- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
720- resp, err := c.request(ctx, http.MethodPost, path, secrets)
721- if err != nil {
722- return err
723- }
724- return parseResponse(resp, nil)
725-}
726-
727-// DeleteAttachmentSecret removes a secret from an attachment container.
728-func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
729- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
730- resp, err := c.request(ctx, http.MethodDelete, path, nil)
731- if err != nil {
732- return err
733- }
734- return parseResponse(resp, nil)
735-}
736-
737523 // Status API
738524
739525 // Status represents the Gordon server status.
740526 type Status struct {
741- Routes int `json:"routes"`
527+ Apps int `json:"apps"`
742528 RegistryDomain string `json:"registry_domain"`
743529 RegistryPort int `json:"registry_port"`
744530 ServerPort int `json:"server_port"`
745- AutoRoute bool `json:"auto_route"`
746531 NetworkIsolation bool `json:"network_isolation"`
747532 ContainerStatus map[string]string `json:"container_status"`
748533 }
749534
750-// GetTLSStatus returns the public TLS/ACME status.
751535 func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
752536 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
753537 if err != nil {
@@ -840,10 +624,10 @@ func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*d
840624 }
841625
842626 // ListBackups returns backups globally or for a domain.
843-func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
627+func (c *Client) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
844628 path := "/backups"
845- if backupDomain != "" {
846- path += "/" + url.PathEscape(backupDomain)
629+ if app != "" {
630+ path += "/" + url.PathEscape(app)
847631 }
848632
849633 resp, err := c.request(ctx, http.MethodGet, path, nil)
@@ -859,7 +643,8 @@ func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.Ba
859643 return result.Backups, nil
860644 }
861645
862-// BackupStatus returns aggregate backup status.
646+// BackupStatus returns stored backups plus declared targets that have no
647+// completed backup yet.
863648 func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
864649 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
865650 if err != nil {
@@ -874,13 +659,15 @@ func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
874659 return result.Backups, nil
875660 }
876661
877-// RunBackup triggers a backup for a domain.
878-func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
879- if backupDomain == "" {
880- return nil, fmt.Errorf("domain cannot be empty")
662+// RunBackup runs one declared database backup of one app. service and
663+// database are explicit selectors.
664+func (c *Client) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) {
665+ if app == "" {
666+ return nil, fmt.Errorf("app cannot be empty")
881667 }
882668
883- resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
669+ resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(app),
670+ dto.BackupRunRequest{Service: service, Database: database})
884671 if err != nil {
885672 return nil, err
886673 }
@@ -893,30 +680,11 @@ func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*d
893680 return &result, nil
894681 }
895682
896-// DetectDatabases detects supported databases for a domain.
897-func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
898- if backupDomain == "" {
899- return nil, fmt.Errorf("domain cannot be empty")
900- }
901-
902- resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
903- if err != nil {
904- return nil, err
905- }
906-
907- var result dto.BackupDetectResponse
908- if err := parseResponse(resp, &result); err != nil {
909- return nil, err
910- }
911-
912- return result.Databases, nil
913-}
914-
915-// ListVolumeBackups returns volume backups globally or for a domain.
916-func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
683+// ListVolumeBackups returns volume backups globally or for one app.
684+func (c *Client) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
917685 path := "/backups/volumes"
918- if backupDomain != "" {
919- path += "/" + url.PathEscape(backupDomain)
686+ if app != "" {
687+ path += "/" + url.PathEscape(app)
920688 }
921689
922690 resp, err := c.request(ctx, http.MethodGet, path, nil)
@@ -945,14 +713,15 @@ func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob,
945713 return result.Backups, nil
946714 }
947715
948-// RunVolumeBackups triggers volume backups.
949-func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
950- path := "/backups/volumes"
951- if backupDomain != "" {
952- path += "/" + url.PathEscape(backupDomain)
716+// RunVolumeBackups runs one declared volume backup of one app. service
717+// and volume are explicit selectors.
718+func (c *Client) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) {
719+ if app == "" {
720+ return nil, fmt.Errorf("app cannot be empty")
953721 }
722+ path := "/backups/volumes/" + url.PathEscape(app)
954723
955- resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
724+ resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Service: service, Volume: volume})
956725 if err != nil {
957726 return nil, err
958727 }
@@ -1006,9 +775,6 @@ type Config struct {
1006775 RegistryDomain string `json:"registry_domain"`
1007776 DataDir string `json:"data_dir,omitempty"`
1008777 } `json:"server"`
1009- AutoRoute struct {
1010- Enabled bool `json:"enabled"`
1011- } `json:"auto_route"`
1012778 NetworkIsolation struct {
1013779 Enabled bool `json:"enabled"`
1014780 Prefix string `json:"prefix"`
@@ -1018,7 +784,6 @@ type Config struct {
1018784 Prefix string `json:"prefix"`
1019785 Preserve bool `json:"preserve"`
1020786 } `json:"volumes"`
1021- Routes []domain.Route `json:"routes"`
1022787 ExternalRoutes []ExternalRoute `json:"external_routes"`
1023788 }
1024789
@@ -1029,97 +794,43 @@ type ExternalRoute struct {
1029794 }
1030795
1031796 // GetConfig returns the Gordon configuration.
1032-func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
1033- resp, err := c.request(ctx, http.MethodGet, "/config", nil)
797+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
798+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
1034799 if err != nil {
1035800 return nil, err
1036801 }
1037802
1038- var config Config
1039- if err := parseResponse(resp, &config); err != nil {
1040- return nil, err
1041- }
1042-
1043- return &config, nil
1044-}
1045-
1046-// Ping checks if the remote Gordon instance is reachable.
1047-func (c *Client) Ping(ctx context.Context) error {
1048- _, err := c.GetStatus(ctx)
1049- return err
1050-}
1051-
1052-// Deploy API
1053-
1054-// DeployResult contains the result of a deployment.
1055-type DeployResult struct {
1056- Status string `json:"status"`
1057- ContainerID string `json:"container_id"`
1058- Domain string `json:"domain"`
1059-}
1060-
1061-// Deploy triggers a deployment for the specified domain.
1062-func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
1063- resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
1064- if err != nil {
1065- return nil, err
803+ var result struct {
804+ Networks []*domain.NetworkInfo `json:"networks"`
1066805 }
1067-
1068- var result DeployResult
1069806 if err := parseResponse(resp, &result); err != nil {
1070807 return nil, err
1071808 }
1072809
1073- return &result, nil
1074-}
1075-
1076-// DeployIntent tells the server that a CLI-managed push is about to happen,
1077-// suppressing event-based deploys for this image.
1078-func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
1079- imageName = strings.TrimSpace(imageName)
1080- if imageName == "" {
1081- return fmt.Errorf("image name cannot be empty")
1082- }
1083- resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
1084- if err != nil {
1085- return err
1086- }
1087- return parseResponse(resp, nil)
1088-}
1089-
1090-// Restart API
1091-
1092-// RestartResult contains the result of a restart.
1093-type RestartResult struct {
1094- Status string `json:"status"`
1095- Domain string `json:"domain"`
810+ return result.Networks, nil
1096811 }
1097812
1098-// Restart triggers a container restart for the specified domain.
1099-func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
1100- if restartDomain == "" {
1101- return nil, fmt.Errorf("domain cannot be empty")
1102- }
1103- path := "/restart/" + url.PathEscape(restartDomain)
1104- if withAttachments {
1105- path += "?attachments=true"
1106- }
1107- resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
813+// GetConfig returns the Gordon configuration.
814+func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
815+ resp, err := c.request(ctx, http.MethodGet, "/config", nil)
1108816 if err != nil {
1109817 return nil, err
1110818 }
1111819
1112- var result RestartResult
1113- if err := parseResponse(resp, &result); err != nil {
820+ var config Config
821+ if err := parseResponse(resp, &config); err != nil {
1114822 return nil, err
1115823 }
1116824
1117- return &result, nil
825+ return &config, nil
1118826 }
1119827
1120-// Tags API
828+// Ping checks if the remote Gordon instance is reachable.
829+func (c *Client) Ping(ctx context.Context) error {
830+ _, err := c.GetStatus(ctx)
831+ return err
832+}
1121833
1122-// ListTags returns available tags for a repository.
1123834 func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
1124835 if repository == "" {
1125836 return nil, fmt.Errorf("repository cannot be empty")
@@ -1194,169 +905,6 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line
1194905 // Attachments Config API
1195906
1196907 // ListOrphanedAttachments returns running attachment containers no longer configured.
1197-func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1198- resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil)
1199- if err != nil {
1200- return nil, err
1201- }
1202- var result struct {
1203- Attachments []domain.CleanupAttachment `json:"attachments"`
1204- }
1205- if err := parseResponse(resp, &result); err != nil {
1206- return nil, err
1207- }
1208- return result.Attachments, nil
1209-}
1210-
1211-// CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers.
1212-func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
1213- path := "/attachments/prune"
1214- params := url.Values{}
1215- if stop {
1216- params.Set("stop", "true")
1217- }
1218- if owner != "" {
1219- params.Set("owner", owner)
1220- }
1221- if encoded := params.Encode(); encoded != "" {
1222- path += "?" + encoded
1223- }
1224- resp, err := c.request(ctx, http.MethodPost, path, nil)
1225- if err != nil {
1226- return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
1227- }
1228- var result domain.CleanupReport
1229- if err := parseResponse(resp, &result); err != nil {
1230- return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
1231- }
1232- return &result, nil
1233-}
1234-
1235-// GetAllAttachmentsConfig returns all configured attachments.
1236-func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
1237- resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
1238- if err != nil {
1239- return nil, err
1240- }
1241-
1242- var result struct {
1243- Attachments map[string][]string `json:"attachments"`
1244- }
1245- if err := parseResponse(resp, &result); err != nil {
1246- return nil, err
1247- }
1248-
1249- return result.Attachments, nil
1250-}
1251-
1252-// GetAttachmentsConfig returns attachments for a specific domain/group from config.
1253-func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
1254- if domainOrGroup == "" {
1255- return nil, fmt.Errorf("domain or group is required")
1256- }
1257- path := "/attachments/" + url.PathEscape(domainOrGroup)
1258- resp, err := c.request(ctx, http.MethodGet, path, nil)
1259- if err != nil {
1260- return nil, err
1261- }
1262-
1263- var result struct {
1264- Target string `json:"target"`
1265- Images []string `json:"images"`
1266- }
1267- if err := parseResponse(resp, &result); err != nil {
1268- return nil, err
1269- }
1270-
1271- return result.Images, nil
1272-}
1273-
1274-// FindAttachmentTargetsByImage returns all attachment targets associated with the given image name.
1275-func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
1276- resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil)
1277- if err != nil {
1278- return nil, err
1279- }
1280-
1281- var result dto.AttachmentTargetsByImageResponse
1282- if err := parseResponse(resp, &result); err != nil {
1283- return nil, err
1284- }
1285-
1286- return result.Targets, nil
1287-}
1288-
1289-// AddAttachment adds an attachment to a domain/group.
1290-func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1291- if domainOrGroup == "" {
1292- return fmt.Errorf("domain or group is required")
1293- }
1294- if image == "" {
1295- return fmt.Errorf("image is required")
1296- }
1297- path := "/attachments/" + url.PathEscape(domainOrGroup)
1298- resp, err := c.request(ctx, http.MethodPost, path, struct {
1299- Image string `json:"image"`
1300- }{Image: image})
1301- if err != nil {
1302- return err
1303- }
1304- return parseResponse(resp, nil)
1305-}
1306-
1307-// RemoveAttachment removes an attachment from a domain/group.
1308-func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1309- if domainOrGroup == "" {
1310- return fmt.Errorf("domain or group is required")
1311- }
1312- if image == "" {
1313- return fmt.Errorf("image is required")
1314- }
1315- path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
1316- resp, err := c.request(ctx, http.MethodDelete, path, nil)
1317- if err != nil {
1318- return err
1319- }
1320- return parseResponse(resp, nil)
1321-}
1322-
1323-func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
1324- resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
1325- if err != nil {
1326- return nil, err
1327- }
1328-
1329- var result dto.AutoRouteAllowedDomainsResponse
1330- if err := parseResponse(resp, &result); err != nil {
1331- return nil, err
1332- }
1333-
1334- return result.Domains, nil
1335-}
1336-
1337-func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1338- if strings.TrimSpace(pattern) == "" {
1339- return fmt.Errorf("pattern must not be empty")
1340- }
1341- resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
1342- if err != nil {
1343- return err
1344- }
1345- return parseResponse(resp, nil)
1346-}
1347-
1348-func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1349- if strings.TrimSpace(pattern) == "" {
1350- return fmt.Errorf("pattern must not be empty")
1351- }
1352- resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
1353- if err != nil {
1354- return err
1355- }
1356- return parseResponse(resp, nil)
1357-}
1358-
1359-// openSSEStream opens an SSE connection to the given admin path and returns the response.
1360908 func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1361909 streamURL := c.baseURL + "/admin" + path
1362910
@@ -1377,7 +925,8 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
1377925 // Use the same transport as the main client (honoring TLS config and
1378926 // custom transports) but without a timeout so streaming doesn't get cut off.
1379927 streamClient := &http.Client{
1380- Transport: c.httpClient.Transport,
928+ Transport: c.httpClient.Transport,
929+ CheckRedirect: c.httpClient.CheckRedirect,
1381930 }
1382931 resp, err := streamClient.Do(req)
1383932 if err != nil {
M internal/adapters/in/cli/remote/client_test.go +8 -167
@@ -17,7 +17,6 @@ import (
1717 "github.com/stretchr/testify/require"
1818
1919 "github.com/bnema/gordon/internal/adapters/dto"
20- "github.com/bnema/gordon/internal/domain"
2120 )
2221
2322 func withFastRetry(t *testing.T) {
@@ -32,50 +31,6 @@ func withFastRetry(t *testing.T) {
3231 })
3332 }
3433
35-func TestClientRestartDoesNotRetryGatewayResponseAfterSideEffect(t *testing.T) {
36- withFastRetry(t)
37-
38- var mutations int32
39- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40- require.Equal(t, "/admin/restart/test.example.com", r.URL.Path)
41- require.Equal(t, http.MethodPost, r.Method)
42- atomic.AddInt32(&mutations, 1)
43- w.WriteHeader(http.StatusServiceUnavailable)
44- _, _ = w.Write([]byte(`{"error":"upstream response unavailable"}`))
45- }))
46- defer srv.Close()
47-
48- client := NewClient(srv.URL)
49- result, err := client.Restart(context.Background(), "test.example.com", false)
50-
51- require.Nil(t, result)
52- var outcomeErr *OutcomeUnknownError
53- require.ErrorAs(t, err, &outcomeErr)
54- assert.Contains(t, err.Error(), "inspect current state before retrying")
55- assert.EqualValues(t, 1, atomic.LoadInt32(&mutations))
56-}
57-
58-func TestClientRestartDoesNotRetryAmbiguousTransportFailure(t *testing.T) {
59- withFastRetry(t)
60-
61- var mutations int32
62- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
63- atomic.AddInt32(&mutations, 1)
64- conn, _, err := w.(http.Hijacker).Hijack()
65- require.NoError(t, err)
66- require.NoError(t, conn.Close())
67- }))
68- defer srv.Close()
69-
70- client := NewClient(srv.URL)
71- result, err := client.Restart(context.Background(), "test.example.com", true)
72-
73- require.Nil(t, result)
74- var outcomeErr *OutcomeUnknownError
75- require.ErrorAs(t, err, &outcomeErr)
76- assert.EqualValues(t, 1, atomic.LoadInt32(&mutations))
77-}
78-
7934 func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
8035 withFastRetry(t)
8136
@@ -97,73 +52,6 @@ func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
9752 assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
9853 }
9954
100-func TestClientDeployDoesNotRetryGatewayResponse(t *testing.T) {
101- withFastRetry(t)
102-
103- var attempts int32
104- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105- require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path)
106- require.Equal(t, http.MethodPost, r.Method)
107- atomic.AddInt32(&attempts, 1)
108- w.WriteHeader(http.StatusBadGateway)
109- _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
110- }))
111- defer srv.Close()
112-
113- client := NewClient(srv.URL)
114- result, err := client.Deploy(context.Background(), "test.example.com")
115-
116- require.Nil(t, result)
117- var outcomeErr *OutcomeUnknownError
118- require.ErrorAs(t, err, &outcomeErr)
119- assert.ErrorContains(t, err, "502 Bad Gateway: upstream unavailable")
120- assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
121-}
122-
123-func TestClientDeployConfirmedRejectionIsNotOutcomeUnknown(t *testing.T) {
124- var attempts int32
125- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126- atomic.AddInt32(&attempts, 1)
127- w.WriteHeader(http.StatusUnprocessableEntity)
128- _, _ = w.Write([]byte(`{"error":"invalid deployment"}`))
129- }))
130- defer srv.Close()
131-
132- client := NewClient(srv.URL)
133- result, err := client.Deploy(context.Background(), "test.example.com")
134-
135- require.Nil(t, result)
136- var outcomeErr *OutcomeUnknownError
137- assert.NotErrorAs(t, err, &outcomeErr)
138- var httpErr *HTTPError
139- require.ErrorAs(t, err, &httpErr)
140- assert.Equal(t, http.StatusUnprocessableEntity, httpErr.StatusCode)
141- assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
142-}
143-
144-func TestClientDeployAuthenticationRejectionIsNotOutcomeUnknown(t *testing.T) {
145- var deployAttempts int32
146- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147- if r.URL.Path == "/auth/token" {
148- w.WriteHeader(http.StatusForbidden)
149- _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
150- return
151- }
152- atomic.AddInt32(&deployAttempts, 1)
153- w.WriteHeader(http.StatusNoContent)
154- }))
155- defer srv.Close()
156-
157- client := NewClient(srv.URL, WithToken("invalid-token"))
158- result, err := client.Deploy(context.Background(), "test.example.com")
159-
160- require.Nil(t, result)
161- var outcomeErr *OutcomeUnknownError
162- assert.NotErrorAs(t, err, &outcomeErr)
163- assert.ErrorContains(t, err, "ephemeral token exchange: 403 Forbidden")
164- assert.Zero(t, atomic.LoadInt32(&deployAttempts))
165-}
166-
16755 func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) {
16856 withFastRetry(t)
16957
@@ -197,7 +85,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
19785 return
19886 }
19987 w.Header().Set("Content-Type", "application/json")
200- _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))
88+ _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
20189 }))
20290 defer srv.Close()
20391
@@ -206,7 +94,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
20694
20795 require.NoError(t, err)
20896 require.NotNil(t, status)
209- assert.Equal(t, 2, status.Routes)
97+ assert.Equal(t, 2, status.Apps)
21098 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
21199 }
212100
@@ -222,7 +110,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
222110 return
223111 }
224112 w.Header().Set("Content-Type", "application/json")
225- _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))
113+ _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
226114 }))
227115 defer srv.Close()
228116
@@ -231,7 +119,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
231119
232120 require.NoError(t, err)
233121 require.NotNil(t, status)
234- assert.Equal(t, 2, status.Routes)
122+ assert.Equal(t, 2, status.Apps)
235123 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
236124 }
237125
@@ -259,7 +147,7 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
259147 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260148 require.Equal(t, "/admin/status", r.URL.Path)
261149 w.Header().Set("Content-Type", "application/json")
262- _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
150+ _, _ = w.Write([]byte(`{"apps":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
263151 }))
264152 defer srv.Close()
265153
@@ -355,53 +243,6 @@ func TestClientPruneImages(t *testing.T) {
355243 assert.Equal(t, 3, resp.Registry.TagsRemoved)
356244 }
357245
358-func TestClientFindAttachmentTargetsByImage(t *testing.T) {
359- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
360- require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path)
361- require.Equal(t, "", r.URL.RawQuery)
362- require.Equal(t, http.MethodGet, r.Method)
363- w.Header().Set("Content-Type", "application/json")
364- _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`))
365- }))
366- defer srv.Close()
367-
368- client := NewClient(srv.URL)
369- targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16")
370- require.NoError(t, err)
371- assert.Equal(t, []string{"app.example.com", "workers"}, targets)
372-}
373-
374-func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) {
375- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
376- require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path)
377- require.Equal(t, http.MethodGet, r.Method)
378- w.WriteHeader(http.StatusNotFound)
379- _, _ = w.Write([]byte(`{"error":"route not found"}`))
380- }))
381- defer srv.Close()
382-
383- client := NewClient(srv.URL)
384- route, err := client.GetRoute(context.Background(), "missing.example.test")
385- require.Nil(t, route)
386- require.Error(t, err)
387- assert.ErrorIs(t, err, domain.ErrRouteNotFound)
388-}
389-
390-func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) {
391- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
392- require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path)
393- require.Equal(t, http.MethodGet, r.Method)
394- w.Header().Set("Content-Type", "application/json")
395- _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`))
396- }))
397- defer srv.Close()
398-
399- client := NewClient(srv.URL)
400- targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag")
401- require.NoError(t, err)
402- assert.Equal(t, []string{"workers"}, targets)
403-}
404-
405246 func TestParseResponse_CapsErrorBodySize(t *testing.T) {
406247 largeBody := strings.Repeat("x", 10*1024*1024)
407248 resp := &http.Response{
@@ -589,20 +430,20 @@ func TestParseErrorResponse_NonJSON(t *testing.T) {
589430
590431 func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
591432 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
592- require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path)
433+ require.Equal(t, "/admin/backups/volumes/shop", r.URL.Path)
593434 require.Equal(t, http.MethodPost, r.Method)
594435 w.Header().Set("Content-Type", "application/json")
595436 w.WriteHeader(http.StatusPartialContent)
596437 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
597438 Status: "partial",
598- Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
439+ Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
599440 Error: "one volume failed",
600441 }))
601442 }))
602443 defer srv.Close()
603444
604445 client := NewClient(srv.URL)
605- result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")
446+ result, err := client.RunVolumeBackups(context.Background(), "shop", "api", "data")
606447
607448 require.Error(t, err)
608449 assert.Contains(t, err.Error(), "one volume failed")
A internal/adapters/in/cli/remote/local.go +106 -0
@@ -0,0 +1,106 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io/fs"
8+ "net"
9+ "net/http"
10+ "os"
11+ "path/filepath"
12+ "syscall"
13+ "time"
14+
15+ "github.com/bnema/gordon/internal/adapters/localadmin"
16+)
17+
18+// LocalBaseURL is the synthetic origin of the Unix-socket admin client. The
19+// transport ignores the host and always dials the owner-only socket.
20+const LocalBaseURL = "http://gordon.local"
21+
22+// ErrDaemonUnavailable reports that no owner-only admin socket passed
23+// validation, so no local daemon can be reached.
24+var ErrDaemonUnavailable = errors.New("daemon-unavailable")
25+
26+// errLocalRedirect refuses redirects on the local socket: a redirect can never
27+// be a legitimate part of the owner-only admin surface.
28+var errLocalRedirect = errors.New("local admin client does not follow redirects")
29+
30+// localSocketDirs lists the discovery candidates in daemon priority order.
31+// It is a variable so tests can isolate from a real daemon.
32+var localSocketDirs = localadmin.ClientDirCandidates
33+
34+// DiscoverLocalSocket returns the first admin socket candidate, in daemon
35+// priority order, that is an owner-owned Unix socket without group/other
36+// permission bits.
37+func DiscoverLocalSocket() (string, error) {
38+ return DiscoverLocalSocketIn(localSocketDirs())
39+}
40+
41+// DiscoverLocalSocketIn is DiscoverLocalSocket over explicit candidate
42+// directories. Each candidate is validated, never followed through symlinks.
43+func DiscoverLocalSocketIn(dirs []string) (string, error) {
44+ var lastErr error
45+ for _, dir := range dirs {
46+ path := filepath.Join(dir, localadmin.SocketName)
47+ if _, err := os.Lstat(path); err != nil {
48+ lastErr = err
49+ continue
50+ }
51+ if err := localadmin.ValidateDir(dir); err != nil {
52+ return "", fmt.Errorf("%w: unsafe local admin directory: %w", ErrDaemonUnavailable, err)
53+ }
54+ if err := localadmin.ValidateSocket(path); err != nil {
55+ return "", fmt.Errorf("%w: unsafe local admin socket: %w", ErrDaemonUnavailable, err)
56+ }
57+ live, err := localadmin.ProbeSocket(path)
58+ if err != nil {
59+ return "", fmt.Errorf("%w: probe local admin socket: %w", ErrDaemonUnavailable, err)
60+ }
61+ if !live {
62+ lastErr = syscall.ECONNREFUSED
63+ continue
64+ }
65+ return path, nil
66+ }
67+ if lastErr == nil {
68+ lastErr = fs.ErrNotExist
69+ }
70+ return "", fmt.Errorf("%w: no owner-only admin socket found: %w", ErrDaemonUnavailable, lastErr)
71+}
72+
73+// NewLocalClient discovers the daemon's owner-only admin socket and returns a
74+// client bound to it. It returns ErrDaemonUnavailable when no daemon is
75+// reachable; callers must not fall back to local writes.
76+func NewLocalClient() (*Client, error) {
77+ socketPath, err := DiscoverLocalSocket()
78+ if err != nil {
79+ return nil, err
80+ }
81+ return NewLocalClientForSocket(socketPath), nil
82+}
83+
84+// NewLocalClientForSocket returns a client whose transport dials only the
85+// given Unix socket. It sends no Authorization header and never exchanges a
86+// token, so it works with auth.enabled=false.
87+func NewLocalClientForSocket(socketPath string) *Client {
88+ transport := &http.Transport{
89+ // Never consult environment proxies for the local socket. DialContext
90+ // authenticates SO_PEERCRED before the transport can write request bytes.
91+ Proxy: nil,
92+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
93+ return localadmin.DialContext(ctx, socketPath)
94+ },
95+ }
96+
97+ httpClient := &http.Client{
98+ Transport: transport,
99+ Timeout: 2 * time.Minute,
100+ CheckRedirect: func(*http.Request, []*http.Request) error {
101+ return errLocalRedirect
102+ },
103+ }
104+
105+ return NewClient(LocalBaseURL, WithHTTPClient(httpClient))
106+}
A internal/adapters/in/cli/remote/local_test.go +210 -0
@@ -0,0 +1,210 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/localadmin"
17+)
18+
19+func mustListenUnix(t *testing.T, dir string, handler http.Handler) string {
20+ t.Helper()
21+ ln, _, err := localadmin.Listen(dir)
22+ require.NoError(t, err)
23+
24+ srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
25+ go func() { _ = srv.Serve(ln) }()
26+ t.Cleanup(func() { _ = srv.Close() })
27+
28+ return localadmin.SocketPath(dir)
29+}
30+
31+func TestLocalAdminDiscoveryRejectsUnsafeSelectedCandidate(t *testing.T) {
32+ unsafeDir := t.TempDir()
33+ goodDir := t.TempDir()
34+
35+ unsafeLn, _, err := localadmin.Listen(unsafeDir)
36+ require.NoError(t, err)
37+ t.Cleanup(func() { _ = unsafeLn.Close() })
38+ require.NoError(t, os.Chmod(localadmin.SocketPath(unsafeDir), 0o666))
39+
40+ goodLn, _, err := localadmin.Listen(goodDir)
41+ require.NoError(t, err)
42+ t.Cleanup(func() { _ = goodLn.Close() })
43+
44+ missingDir := t.TempDir()
45+
46+ _, err = DiscoverLocalSocketIn([]string{missingDir, unsafeDir, goodDir})
47+ require.Error(t, err)
48+ assert.ErrorIs(t, err, ErrDaemonUnavailable)
49+ assert.ErrorIs(t, err, localadmin.ErrUnsafePath)
50+}
51+
52+func TestLocalAdminDiscoveryReportsUnavailable(t *testing.T) {
53+ dir := t.TempDir()
54+ _, err := DiscoverLocalSocketIn([]string{dir})
55+ require.Error(t, err)
56+ assert.ErrorIs(t, err, ErrDaemonUnavailable)
57+}
58+
59+func TestLocalAdminDiscoveryUsesXDGCandidates(t *testing.T) {
60+ xdg := t.TempDir()
61+ t.Setenv("XDG_RUNTIME_DIR", xdg)
62+
63+ ln, _, err := localadmin.Listen(filepath.Join(xdg, "gordon"))
64+ require.NoError(t, err)
65+ t.Cleanup(func() { _ = ln.Close() })
66+
67+ got, err := DiscoverLocalSocket()
68+ require.NoError(t, err)
69+ assert.Equal(t, localadmin.SocketPath(filepath.Join(xdg, "gordon")), got)
70+}
71+
72+func TestLocalAdminClientFailsWithoutSocket(t *testing.T) {
73+ dirs := []string{t.TempDir(), t.TempDir()}
74+ restore := localSocketDirs
75+ localSocketDirs = func() []string { return dirs }
76+ t.Cleanup(func() { localSocketDirs = restore })
77+
78+ _, err := NewLocalClient()
79+ require.Error(t, err)
80+ assert.ErrorIs(t, err, ErrDaemonUnavailable)
81+}
82+
83+// TestLocalAdminClientSendsNoTokenAndNoExchange proves tokenless local
84+// requests: no Authorization header and no /auth/token call.
85+func TestLocalAdminClientSendsNoTokenAndNoExchange(t *testing.T) {
86+ dir := t.TempDir()
87+ type captured struct {
88+ path string
89+ auth string
90+ method string
91+ }
92+ requests := make(chan captured, 4)
93+
94+ socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ requests <- captured{path: r.URL.Path, auth: r.Header.Get("Authorization"), method: r.Method}
96+ w.Header().Set("Content-Type", "application/json")
97+ _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}})
98+ }))
99+
100+ client := NewLocalClientForSocket(socketPath)
101+ apps, err := client.ListApps(context.Background())
102+ require.NoError(t, err)
103+ assert.Empty(t, apps)
104+
105+ select {
106+ case got := <-requests:
107+ assert.Equal(t, "/admin/apps", got.path)
108+ assert.Empty(t, got.auth)
109+ case <-time.After(2 * time.Second):
110+ t.Fatal("no request captured")
111+ }
112+
113+ select {
114+ case extra := <-requests:
115+ t.Fatalf("unexpected extra request: %+v", extra)
116+ case <-time.After(100 * time.Millisecond):
117+ }
118+}
119+
120+func TestLocalAdminClientIgnoresEnvironmentProxy(t *testing.T) {
121+ dir := t.TempDir()
122+ requests := make(chan string, 1)
123+
124+ socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+ requests <- r.URL.Path
126+ w.Header().Set("Content-Type", "application/json")
127+ _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}})
128+ }))
129+
130+ t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
131+ t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
132+
133+ client := NewLocalClientForSocket(socketPath)
134+ _, err := client.ListApps(context.Background())
135+ require.NoError(t, err)
136+
137+ select {
138+ case got := <-requests:
139+ assert.Equal(t, "/admin/apps", got)
140+ case <-time.After(2 * time.Second):
141+ t.Fatal("no request captured")
142+ }
143+}
144+
145+func TestLocalAdminClientRejectsRedirects(t *testing.T) {
146+ dir := t.TempDir()
147+ socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148+ if r.URL.Path == "/admin/apps" {
149+ http.Redirect(w, r, "http://evil.example/admin/apps", http.StatusFound)
150+ return
151+ }
152+ w.WriteHeader(http.StatusTeapot)
153+ }))
154+
155+ client := NewLocalClientForSocket(socketPath)
156+ _, err := client.ListApps(context.Background())
157+ require.Error(t, err)
158+ assert.Contains(t, err.Error(), "redirect")
159+}
160+
161+func TestLocalAdminClientRespectsCancellation(t *testing.T) {
162+ dir := t.TempDir()
163+ release := make(chan struct{})
164+ socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ <-release
166+ }))
167+
168+ ctx, cancel := context.WithCancel(context.Background())
169+ done := make(chan error, 1)
170+ go func() {
171+ client := NewLocalClientForSocket(socketPath)
172+ _, err := client.ListApps(ctx)
173+ done <- err
174+ }()
175+
176+ cancel()
177+ select {
178+ case err := <-done:
179+ require.Error(t, err)
180+ assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), "got %v", err)
181+ case <-time.After(3 * time.Second):
182+ t.Fatal("request did not honor cancellation")
183+ }
184+ close(release)
185+}
186+
187+func TestLocalAdminClientFailsOnMissingSocket(t *testing.T) {
188+ client := NewLocalClientForSocket(filepath.Join(t.TempDir(), "absent.sock"))
189+ _, err := client.ListApps(context.Background())
190+ require.Error(t, err)
191+
192+ var transportErr *requestTransportError
193+ assert.True(t, errors.As(err, &transportErr), "expected transport error, got %T: %v", err, err)
194+}
195+
196+func TestLocalAdminTransportDialsUnixOnly(t *testing.T) {
197+ dir := t.TempDir()
198+ socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
199+ w.WriteHeader(http.StatusNoContent)
200+ }))
201+
202+ // The synthetic base URL host must never be resolved: only the socket is dialed.
203+ client := NewLocalClientForSocket(socketPath)
204+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, LocalBaseURL+"/admin/apps", nil)
205+ require.NoError(t, err)
206+ resp, err := client.httpClient.Do(req)
207+ require.NoError(t, err)
208+ defer resp.Body.Close()
209+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
210+}
D internal/adapters/in/cli/remote/preview.go +0 -70
@@ -1,70 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "fmt"
6- "net/http"
7- "net/url"
8-
9- "github.com/bnema/gordon/internal/domain"
10-)
11-
12-// ListPreviews returns all active preview environments.
13-func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) {
14- resp, err := c.request(ctx, http.MethodGet, "/previews", nil)
15- if err != nil {
16- return nil, err
17- }
18-
19- var result struct {
20- Previews []domain.PreviewRoute `json:"previews"`
21- }
22- if err := parseResponse(resp, &result); err != nil {
23- return nil, err
24- }
25-
26- return result.Previews, nil
27-}
28-
29-// DeletePreview tears down a preview environment by name.
30-func (c *Client) DeletePreview(ctx context.Context, name string) error {
31- if name == "" {
32- return fmt.Errorf("preview name cannot be empty")
33- }
34- resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil)
35- if err != nil {
36- return err
37- }
38- return parseResponse(resp, nil)
39-}
40-
41-// ExtendPreview extends the TTL of a preview environment.
42-func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error {
43- if name == "" {
44- return fmt.Errorf("preview name cannot be empty")
45- }
46- if ttl == "" {
47- return fmt.Errorf("ttl cannot be empty")
48- }
49- resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct {
50- TTL string `json:"ttl"`
51- }{TTL: ttl})
52- if err != nil {
53- return err
54- }
55- return parseResponse(resp, nil)
56-}
57-
58-// GetPreview returns a single preview by name.
59-func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {
60- previews, err := c.ListPreviews(ctx)
61- if err != nil {
62- return nil, fmt.Errorf("list previews: %w", err)
63- }
64- for _, p := range previews {
65- if p.Name == name {
66- return &p, nil
67- }
68- }
69- return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
70-}
M internal/adapters/in/cli/remote_inference.go +90 -104
@@ -5,20 +5,17 @@ import (
55 "errors"
66 "fmt"
77 "net/http"
8+ "os"
9+ "strconv"
810 "strings"
911
1012 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1113 "github.com/bnema/gordon/internal/domain"
12- "github.com/bnema/gordon/pkg/validation"
1314 )
1415
1516 type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error)
1617
17-func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) {
18- if domainFlag != "" {
19- return inferRemoteForRouteDomain(ctx, domainFlag)
20- }
21-
18+func inferPushRemote(ctx context.Context, imageArg, _, dockerfile string) (*remote.ResolvedRemote, error) {
2219 if imageArg == "" {
2320 detected, err := detectImageName(dockerfile)
2421 if err != nil {
@@ -26,105 +23,20 @@ func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile strin
2623 }
2724 imageArg = detected
2825 }
29-
30- if looksLikeLegacyDomain(imageArg) {
31- lookupImage := imageArg
32- domainLookupArg := imageArg
33- if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
34- domainLookupArg = parsedImage
35- if !strings.HasPrefix(ref, "sha256:") {
36- lookupImage = parsedImage
37- }
38- }
39-
40- resolved, err := inferRemoteForImage(ctx, lookupImage)
41- if err != nil || resolved != nil {
42- return resolved, err
43- }
44-
45- return inferRemoteForRouteDomain(ctx, domainLookupArg)
46- }
47-
4826 classified := classifyPushArgument(imageArg)
49- return inferRemoteForImage(ctx, classified.lookupImage)
27+ return inferRemoteForImage(ctx, classified.repository)
5028 }
5129
5230 func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
5331 return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
54- routes, err := client.FindRoutesByImage(ctx, imageName)
55- if err != nil {
56- return false, err
57- }
58- return len(filterPreviewRoutes(routes)) > 0, nil
59- })
60-}
61-
62-func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
63- return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
64- _, err := client.GetRoute(ctx, routeDomain)
65- if err != nil {
66- if isRemoteNotFoundError(err) {
67- return false, nil
68- }
69- return false, err
70- }
71- return true, nil
72- })
73-}
74-
75-func inferRemoteForRouteCleanupDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
76- return inferSavedRemote(ctx, "route cleanup", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
77- _, err := client.GetRoute(ctx, routeDomain)
78- if err == nil {
79- return true, nil
80- }
81- if !isRemoteNotFoundError(err) {
82- return false, err
83- }
84-
85- preview, err := client.GetRouteCleanupPreview(ctx, routeDomain)
86- if err != nil {
87- return false, err
88- }
89- return routeCleanupReportHasEvidence(preview), nil
90- })
91-}
92-
93-func routeCleanupReportHasEvidence(report *domain.CleanupReport) bool {
94- if report == nil {
95- return false
96- }
97- return len(report.PreservedVolumes) > 0 || len(report.PreservedAttachments) > 0 || len(report.OrphanedEntities) > 0
98-}
99-
100-func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
101- return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
102- targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)
103- if err != nil {
104- return false, err
105- }
106- return len(targets) > 0, nil
107- })
108-}
109-
110-func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) {
111- return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) {
112- images, err := client.GetAttachmentsConfig(ctx, target)
113- if err == nil {
114- return len(images) > 0, nil
115- }
116- if !isRemoteNotFoundError(err) {
117- return false, err
118- }
119-
120- _, err = client.GetRoute(ctx, target)
32+ tags, err := client.ListTags(ctx, imageName)
12133 if err != nil {
12234 if isRemoteNotFoundError(err) {
12335 return false, nil
12436 }
12537 return false, err
12638 }
127- return true, nil
39+ return len(tags) > 0, nil
12840 })
12941 }
13042
@@ -153,7 +65,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo
15365
15466 for _, name := range names {
15567 entry := remotes[name]
156- matched, err := probe(ctx, newRoutesTargetClient(name, entry))
68+ matched, err := probe(ctx, newInferenceTargetClient(name, entry))
15769 if err != nil {
15870 probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err))
15971 continue
@@ -183,7 +95,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo
18395 }
18496
18597 func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) {
186- if _, ok := resolveRoutesExplicitTarget(); ok {
98+ if _, ok := resolveExplicitTargetName(); ok {
18799 return nil, false
188100 }
189101
@@ -205,8 +117,8 @@ func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.Reso
205117 return &remote.ResolvedRemote{
206118 Name: name,
207119 URL: entry.URL,
208- Token: resolveRoutesTokenForTarget(name, entry),
209- InsecureTLS: resolveRoutesInsecureForTarget(name, entry),
120+ Token: resolveTokenForTarget(name, entry),
121+ InsecureTLS: resolveInsecureForTarget(name, entry),
210122 }
211123 }
212124
@@ -217,12 +129,86 @@ func isRemoteNotFoundError(err error) bool {
217129 return errors.Is(err, domain.ErrRouteNotFound)
218130 }
219131
220-func filterPreviewRoutes(routes []domain.Route) []domain.Route {
221- filtered := make([]domain.Route, 0, len(routes))
222- for _, route := range routes {
223- if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {
224- filtered = append(filtered, route)
132+func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) {
133+ target, ok := resolveExplicitTargetName()
134+ if !ok {
135+ return nil, false, nil
136+ }
137+
138+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
139+ return &remote.ResolvedRemote{
140+ URL: target,
141+ Token: resolveTokenForTarget("", remote.RemoteEntry{}),
142+ InsecureTLS: resolveInsecureForTarget("", remote.RemoteEntry{}),
143+ }, true, nil
144+ }
145+
146+ remotes, err := remote.LoadRemotes("")
147+ if err != nil {
148+ return nil, false, err
149+ }
150+
151+ if remotes != nil {
152+ if entry, found := remotes.Remotes[target]; found {
153+ return &remote.ResolvedRemote{
154+ Name: target,
155+ URL: entry.URL,
156+ Token: resolveTokenForTarget(target, entry),
157+ InsecureTLS: resolveInsecureForTarget(target, entry),
158+ }, true, nil
225159 }
226160 }
227- return filtered
161+
162+ return nil, false, nil
163+}
164+
165+func resolveExplicitTargetName() (string, bool) {
166+ if target := strings.TrimSpace(remoteFlag); target != "" {
167+ return target, true
168+ }
169+ if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
170+ return target, true
171+ }
172+ return "", false
173+}
174+
175+func resolveTokenForTarget(name string, entry remote.RemoteEntry) string {
176+ if token := strings.TrimSpace(tokenFlag); token != "" {
177+ return token
178+ }
179+ if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
180+ return token
181+ }
182+ if name != "" {
183+ return remote.ResolveTokenForRemote(name, entry)
184+ }
185+ return ""
186+}
187+
188+func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool {
189+ if insecureTLSFlag {
190+ return true
191+ }
192+ if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
193+ if value, err := strconv.ParseBool(env); err == nil {
194+ return value
195+ }
196+ }
197+ if name != "" {
198+ return entry.InsecureTLS
199+ }
200+ return false
201+}
202+
203+func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
204+ return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...)
205+}
206+
207+// inferExplicitTarget resolves --remote/GORDON_REMOTE without probing saved remotes.
208+func inferExplicitTarget(_ context.Context) (*remote.ResolvedRemote, error) {
209+ resolved, ok, err := resolveExplicitRemote()
210+ if err != nil || !ok {
211+ return nil, err
212+ }
213+ return resolved, nil
228214 }
M internal/adapters/in/cli/remotes.go +1 -1
@@ -296,7 +296,7 @@ without needing to specify --remote.
296296
297297 Examples:
298298 gordon remotes use prod
299- gordon routes list # Uses prod remote automatically`,
299+ gordon status # Uses prod remote automatically`,
300300 Args: cobra.ExactArgs(1),
301301 RunE: func(cmd *cobra.Command, args []string) error {
302302 name := args[0]
D internal/adapters/in/cli/restart.go +0 -58
@@ -1,58 +0,0 @@
1-package cli
2-
3-import (
4- "fmt"
5-
6- "github.com/spf13/cobra"
7-
8- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
9- "github.com/bnema/gordon/internal/app"
10-)
11-
12-func newRestartCmd() *cobra.Command {
13- var withAttachments bool
14-
15- cmd := &cobra.Command{
16- Use: "restart <domain>",
17- Short: "Restart a running container",
18- Long: `Restarts the container for the specified route domain.
19-Useful after changing environment variables with 'gordon secrets set'.
20-
21-Use --with-attachments to also restart attached services (databases, caches).
22-
23-Examples:
24- gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
25- gordon restart myapp.example.com --with-attachments --remote ...`,
26- Args: cobra.ExactArgs(1),
27- RunE: func(cmd *cobra.Command, args []string) error {
28- ctx := cmd.Context()
29- restartDomain := args[0]
30-
31- handle, err := resolveControlPlaneForRouteDomain(ctx, restartDomain)
32- if err != nil {
33- return err
34- }
35- defer handle.close()
36-
37- result, err := handle.plane.Restart(ctx, restartDomain, withAttachments)
38- if err != nil {
39- if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) {
40- domain, localErr := app.SendDeploySignal(restartDomain)
41- if localErr == nil {
42- fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))
43- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain)))
44- return nil
45- }
46- return fmt.Errorf("remote restart failed: %w; local fallback failed: %v", err, localErr)
47- }
48- return fmt.Errorf("failed to restart: %w", err)
49- }
50- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))
51- return nil
52- },
53- }
54-
55- cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)")
56-
57- return cmd
58-}
M internal/adapters/in/cli/root.go +45 -207
@@ -13,7 +13,6 @@ import (
1313 "github.com/spf13/cobra"
1414
1515 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16- "github.com/bnema/gordon/internal/app"
1716 )
1817
1918 var (
@@ -50,16 +49,16 @@ func NewRootCmd() *cobra.Command {
5049 tokenFlag = token
5150 return nil
5251 },
53- Short: "Gordon - A lightweight container deployment platform",
54- Long: `Gordon is a self-contained container deployment platform that combines
55-a Docker registry with automatic container deployment capabilities.
52+ Short: "Gordon - A self-hosted container deployment platform",
53+ Long: `Gordon is a self-hosted container deployment platform: a private
54+container registry, an app runtime, and a reverse proxy.
5655
57-It listens for image pushes and automatically deploys containers based on
58-configuration rules, making it ideal for single-server deployments.
56+Applications are declared in standalone TOML files and managed with
57+gordon apps; push transfers OCI content only and never deploys.
5958
6059 Commands are organized by where they run:
61- Server-only: Run on the machine hosting Gordon (serve, auth, reload)
62- Management: Work locally or remotely via --remote flag (routes, secrets, etc.)
60+ Server-only: Run on the machine hosting Gordon (serve, auth, ca)
61+ Management: Work locally or remotely via --remote flag (apps, secrets, etc.)
6362 Client-only: CLI utilities that don't require a running Gordon server`,
6463 }
6564
@@ -88,35 +87,14 @@ Commands are organized by where they run:
8887 caCmd.GroupID = groupServer
8988 rootCmd.AddCommand(caCmd)
9089
91- // Management commands (work locally or via --remote)
92- routesCmd := newRoutesCmd()
93- routesCmd.GroupID = groupManage
94- rootCmd.AddCommand(routesCmd)
95-
96- attachmentsCmd := newAttachmentsCmd()
97- attachmentsCmd.GroupID = groupManage
98- rootCmd.AddCommand(attachmentsCmd)
99-
10090 secretsCmd := newSecretsCmd()
10191 secretsCmd.GroupID = groupManage
10292 rootCmd.AddCommand(secretsCmd)
10393
104- deployCmd := newDeployCmd()
105- deployCmd.GroupID = groupManage
106- rootCmd.AddCommand(deployCmd)
107-
108- restartCmd := newRestartCmd()
109- restartCmd.GroupID = groupManage
110- rootCmd.AddCommand(restartCmd)
111-
11294 pushCmd := newPushCmd()
11395 pushCmd.GroupID = groupManage
11496 rootCmd.AddCommand(pushCmd)
11597
116- pinCmd := newPinCmd()
117- pinCmd.GroupID = groupManage
118- rootCmd.AddCommand(pinCmd)
119-
12098 reloadCmd := newReloadCmd()
12199 reloadCmd.GroupID = groupManage
122100 rootCmd.AddCommand(reloadCmd)
@@ -137,10 +115,6 @@ Commands are organized by where they run:
137115 imagesCmd.GroupID = groupManage
138116 rootCmd.AddCommand(imagesCmd)
139117
140- bootstrapCmd := newBootstrapCmd()
141- bootstrapCmd.GroupID = groupManage
142- rootCmd.AddCommand(bootstrapCmd)
143-
144118 configCmd := newConfigCmd()
145119 configCmd.GroupID = groupManage
146120 rootCmd.AddCommand(configCmd)
@@ -153,14 +127,6 @@ Commands are organized by where they run:
153127 volumesCmd.GroupID = groupManage
154128 rootCmd.AddCommand(volumesCmd)
155129
156- autorouteCmd := newAutorouteCmd()
157- autorouteCmd.GroupID = groupManage
158- rootCmd.AddCommand(autorouteCmd)
159-
160- previewCmd := newPreviewCmd()
161- previewCmd.GroupID = groupManage
162- rootCmd.AddCommand(previewCmd)
163-
164130 tlsCmd := newTLSCmd()
165131 tlsCmd.GroupID = groupManage
166132 rootCmd.AddCommand(tlsCmd)
@@ -169,6 +135,10 @@ Commands are organized by where they run:
169135 trafficCmd.GroupID = groupManage
170136 rootCmd.AddCommand(trafficCmd)
171137
138+ appsCmd := newAppsCmd()
139+ appsCmd.GroupID = groupManage
140+ rootCmd.AddCommand(appsCmd)
141+
172142 // Client-only commands (no server needed)
173143 remotesCmd := newRemotesCmd()
174144 remotesCmd.GroupID = groupClient
@@ -222,21 +192,23 @@ func IsRemoteMode() bool {
222192 func newReloadCmd() *cobra.Command {
223193 return &cobra.Command{
224194 Use: "reload",
225- Short: "Start containers for configured routes",
226- Long: `Starts containers for routes defined in config.toml that don't have
227-a running container. Running containers are never restarted to ensure 100% uptime.
195+ Short: "Reload installation configuration (never activates app state)",
196+ Long: `Reloads installation-only settings (entrypoints, TLS, limits,
197+external routes) after editing gordon.toml.
228198
229-Use this command after editing config.toml to add new routes, or after pushing
230-images to the registry when the route was not yet configured.`,
199+Reload never activates pending app desired state, never re-resolves
200+image tags, and never starts app workloads. Apps are managed with
201+gordon apps (apply, deploy, lifecycle).`,
231202 RunE: func(cmd *cobra.Command, args []string) error {
232- client, isRemote, err := GetRemoteClient()
203+ handle, err := resolveControlPlane(cliConfigPath)
233204 if err != nil {
234205 return err
235206 }
236- if isRemote {
237- return runReloadRemote(cmd.Context(), client)
207+ defer handle.close()
208+ if err := handle.plane.Reload(cmd.Context()); err != nil {
209+ return fmt.Errorf("failed to reload: %w", err)
238210 }
239- return runReload()
211+ return cliWriteLine(cmd.OutOrStdout(), cliRenderSuccess("Configuration reloaded successfully"))
240212 },
241213 }
242214 }
@@ -262,34 +234,9 @@ func newVersionCmd() *cobra.Command {
262234 }
263235 }
264236
265-// runReload sends SIGUSR1 to the running Gordon process.
266-func runReload() error {
267- return app.SendReloadSignal()
268-}
269-
270-// runReloadRemote triggers a reload on a remote Gordon instance.
271-func runReloadRemote(ctx context.Context, client *remote.Client) error {
272- if err := client.Reload(ctx); err != nil {
273- if shouldFallbackToLocal(err) {
274- localErr := runReload()
275- if localErr == nil {
276- if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
277- return writeErr
278- }
279- if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
280- return writeErr
281- }
282- return nil
283- }
284- return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
285- }
286- return fmt.Errorf("failed to reload: %w", err)
287- }
288- if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
289- return err
290- }
291- return nil
292-}
237+// cliConfigPath for local operations. If empty, config is auto-discovered
238+// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
239+var cliConfigPath string
293240
294241 // newLogsCmd creates the logs command.
295242 func newLogsCmd() *cobra.Command {
@@ -298,29 +245,23 @@ func newLogsCmd() *cobra.Command {
298245 var logsConfigPath string
299246
300247 cmd := &cobra.Command{
301- Use: "logs [domain]",
302- Short: "Show logs (Gordon process or container)",
303- Long: `Shows logs from the Gordon process or a specific container.
248+ Use: "logs",
249+ Short: "Show Gordon process logs",
250+ Long: `Shows logs from the Gordon daemon process.
304251
305-Without a domain argument, shows Gordon process logs.
306-With a domain argument, shows container logs for that domain.
252+Application workload output is read with gordon apps logs APP --service SVC,
253+which resolves the app's active container through the daemon.
307254
308255 Examples:
309- gordon logs # Gordon process logs
310- gordon logs -f # Follow process logs
311- gordon logs myapp.local # Container logs for myapp.local
312- gordon logs myapp.local -f # Follow container logs
256+ gordon logs # Gordon process logs
257+ gordon logs -f # Follow process logs
258+ gordon logs -n 100 # Last 100 lines
313259
314260 Remote mode:
315- gordon logs --remote https://gordon.mydomain.com --token $TOKEN
316- gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
317- Args: cobra.MaximumNArgs(1),
261+ gordon logs --remote https://gordon.mydomain.com --token $TOKEN`,
262+ Args: cobra.NoArgs,
318263 RunE: func(cmd *cobra.Command, args []string) error {
319- logDomain := ""
320- if len(args) > 0 {
321- logDomain = args[0]
322- }
323- return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
264+ return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout())
324265 },
325266 }
326267
@@ -332,101 +273,23 @@ Remote mode:
332273 }
333274
334275 // runLogs handles the logs command logic.
335-func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
336- if logDomain != "" {
337- handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
338- if err != nil {
339- return err
340- }
341- defer handle.close()
342- return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
343- }
344-
345- client, isRemote, err := GetRemoteClient()
276+func runLogs(ctx context.Context, logsConfigPath string, follow bool, lines int, out io.Writer) error {
277+ handle, err := resolveControlPlane(logsConfigPath)
346278 if err != nil {
347279 return err
348280 }
349- if isRemote {
350- return runLogsRemote(ctx, client, logDomain, follow, lines, out)
351- }
352- return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
281+ defer handle.close()
282+ return runProcessLogs(ctx, handle.plane, follow, lines, out)
353283 }
354284
355-// runLogsRemote fetches logs from a remote Gordon instance.
356-func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
285+func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int, out io.Writer) error {
357286 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
358287 defer cancel()
359288
360289 if follow {
361- return streamLogsRemote(ctx, client, logDomain, lines, out)
362- }
363-
364- if logDomain == "" {
365- // Process logs
366- logLines, err := client.GetProcessLogs(ctx, lines)
367- if err != nil {
368- return fmt.Errorf("failed to get process logs: %w", err)
369- }
370- for _, line := range logLines {
371- if err := cliWriteLine(out, line); err != nil {
372- return err
373- }
374- }
375- } else {
376- // Container logs
377- logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
290+ ch, err := cp.StreamProcessLogs(ctx, lines)
378291 if err != nil {
379- return fmt.Errorf("failed to get container logs: %w", err)
380- }
381- for _, line := range logLines {
382- if err := cliWriteLine(out, line); err != nil {
383- return err
384- }
385- }
386- }
387- return nil
388-}
389-
390-// streamLogsRemote streams logs from a remote Gordon instance.
391-func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error {
392- var ch <-chan string
393- var err error
394-
395- if logDomain == "" {
396- ch, err = client.StreamProcessLogs(ctx, lines)
397- } else {
398- ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
399- }
400- if err != nil {
401- return fmt.Errorf("failed to stream logs: %w", err)
402- }
403-
404- for line := range ch {
405- if err := cliWriteLine(out, line); err != nil {
406- return err
407- }
408- }
409- return nil
410-}
411-
412-// runLogsLocal shows logs from a local Gordon instance.
413-func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
414- if logDomain == "" {
415- // Process logs - use existing app.ShowLogs
416- return app.ShowLogs(logsConfigPath, follow, lines)
417- }
418-
419- return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines)
420-}
421-
422-func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error {
423- ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
424- defer cancel()
425-
426- if follow {
427- ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
428- if err != nil {
429- return fmt.Errorf("failed to stream container logs: %w", err)
292+ return fmt.Errorf("failed to stream process logs: %w", err)
430293 }
431294 for line := range ch {
432295 if err := cliWriteLine(out, line); err != nil {
@@ -436,9 +299,9 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo
436299 return nil
437300 }
438301
439- logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
302+ logLines, err := cp.GetProcessLogs(ctx, lines)
440303 if err != nil {
441- return fmt.Errorf("failed to get container logs: %w", err)
304+ return fmt.Errorf("failed to get process logs: %w", err)
442305 }
443306 for _, line := range logLines {
444307 if err := cliWriteLine(out, line); err != nil {
@@ -448,31 +311,6 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo
448311 return nil
449312 }
450313
451-// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
452-func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error {
453- if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
454- return err
455- }
456- if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil {
457- return err
458- }
459- if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
460- return err
461- }
462- if follow {
463- if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
464- return err
465- }
466- }
467- if err := cliWriteLine(out, ""); err != nil {
468- return err
469- }
470- if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
471- return err
472- }
473- return nil
474-}
475-
476314 // SetVersionInfo sets the version information for the CLI.
477315 func SetVersionInfo(version, commit, date string) {
478316 Version = version
D internal/adapters/in/cli/routes.go +0 -1837 collapsed to keep the page light
D internal/adapters/in/cli/routes_test.go +0 -1209 collapsed to keep the page light
M internal/adapters/in/cli/secrets.go +37 -154
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "strings"
77
8- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
98 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
109 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1110
@@ -16,11 +15,12 @@ import (
1615 func newSecretsCmd() *cobra.Command {
1716 cmd := &cobra.Command{
1817 Use: "secrets",
19- Short: "Manage secrets",
20- Long: `Manage secrets (environment variables) for routes and attachments.
18+ Short: "Manage installation secrets",
19+ Long: `Manage installation secrets (per-host entries such as service auth).
2120
22-Secrets are stored per-domain and injected into containers as environment variables.
23-Use --attachment to target attachment containers (databases, caches, etc.).
21+Application secret VALUES are managed with gordon apps secrets and stay
22+in pass under gordon/apps/<uuid>/<service>/<name>. The commands below
23+manage the installation secret store only.
2424
2525 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
2626 these commands operate on the remote server.`,
@@ -43,7 +43,6 @@ func newSecretsListCmd() *cobra.Command {
4343 Long: `List all secret keys configured for a domain.
4444
4545 Note: Only secret keys are shown, not values (for security).
46-Attachment secrets (for services like databases) are also displayed.
4746
4847 Examples:
4948 gordon secrets list app.mydomain.com
@@ -64,28 +63,22 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
6463 ctx := cmd.Context()
6564 secretDomain := args[0]
6665
67- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
66+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
6867 if err != nil {
6968 return err
7069 }
7170 defer handle.close()
7271
73- keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain)
72+ keys, err := fetchSecrets(ctx, handle.plane, secretDomain)
7473 if err != nil {
7574 return err
7675 }
7776
78- totalSecrets := len(keys)
79- for _, att := range attachments {
80- totalSecrets += len(att.Keys)
81- }
82-
83- if totalSecrets == 0 {
77+ if len(keys) == 0 {
8478 if jsonOut {
8579 return writeJSON(cmd.OutOrStdout(), map[string]any{
86- "domain": secretDomain,
87- "keys": []string{},
88- "attachments": []any{},
80+ "domain": secretDomain,
81+ "keys": []string{},
8982 })
9083 }
9184 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
@@ -93,13 +86,9 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
9386 }
9487
9588 if jsonOut {
96- if attachments == nil {
97- attachments = []remote.AttachmentSecrets{}
98- }
9989 return writeJSON(cmd.OutOrStdout(), map[string]any{
100- "domain": secretDomain,
101- "keys": keys,
102- "attachments": attachments,
90+ "domain": secretDomain,
91+ "keys": keys,
10392 })
10493 }
10594
@@ -110,7 +99,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
11099 fmt.Println(styles.Theme.Title.Render(title))
111100 fmt.Println()
112101
113- rows := buildSecretsTableRows(keys, attachments)
102+ rows := buildSecretsTableRows(keys)
114103
115104 table := components.NewTable(
116105 components.WithColumns([]components.TableColumn{
@@ -124,120 +113,39 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
124113 return nil
125114 }
126115
127-// fetchSecretsWithAttachments retrieves secrets from the selected control plane.
128-func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) {
129- result, err := cp.ListSecretsWithAttachments(ctx, secretDomain)
116+// fetchSecrets retrieves secret keys from the selected control plane.
117+func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) {
118+ result, err := cp.ListSecrets(ctx, secretDomain)
130119 if err != nil {
131- return nil, nil, fmt.Errorf("failed to list secrets: %w", err)
120+ return nil, fmt.Errorf("failed to list secrets: %w", err)
132121 }
133- return result.Keys, result.Attachments, nil
122+ return result.Keys, nil
134123 }
135124
136-// buildSecretsTableRows builds table rows with tree structure for attachments.
137-func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string {
125+// buildSecretsTableRows builds table rows for secret keys.
126+func buildSecretsTableRows(keys []string) [][]string {
138127 var rows [][]string
139-
140- // Domain secrets first
141128 for _, key := range keys {
142129 rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
143130 }
144-
145- // Attachment secrets with tree structure
146- for i, att := range attachments {
147- isLastAttachment := i == len(attachments)-1
148- rows = append(rows, buildAttachmentRows(att, isLastAttachment)...)
149- }
150-
151- return rows
152-}
153-
154-// buildAttachmentRows builds table rows for a single attachment with tree structure.
155-func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string {
156- var rows [][]string
157-
158- // Attachment header with tree prefix
159- prefix := styles.IconTreeBranch + styles.IconTreeLine
160- if isLastAttachment {
161- prefix = styles.IconTreeLast + styles.IconTreeLine
162- }
163-
164- serviceName := extractServiceName(att.Service)
165- attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName)))
166- rows = append(rows, []string{attachmentHeader, ""})
167-
168- // Keys for this attachment with nested tree structure
169- for j, key := range att.Keys {
170- isLastKey := j == len(att.Keys)-1
171- keyPrefix := getKeyPrefix(isLastAttachment, isLastKey)
172- rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")})
173- }
174-
175131 return rows
176132 }
177133
178-// extractServiceName extracts a short service name from a container name.
179-// e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres"
180-func extractServiceName(containerName string) string {
181- if !strings.HasPrefix(containerName, "gordon-") {
182- return containerName
183- }
184-
185- parts := strings.SplitN(containerName, "-", 2)
186- if len(parts) <= 1 {
187- return containerName
188- }
189-
190- serviceName := parts[1]
191- allParts := strings.Split(serviceName, "-")
192-
193- // Handle service names based on the number of segments explicitly
194- if len(allParts) < 2 {
195- // No additional segments; use the service name as-is.
196- return serviceName
197- }
198- if len(allParts) == 2 {
199- // Exactly two segments; the service name is already in the desired form.
200- return strings.Join(allParts, "-")
201- }
202-
203- // More than two segments: take the last two as the short service name (e.g., "gitea-postgres").
204- return strings.Join(allParts[len(allParts)-2:], "-")
205-}
206-
207-// getKeyPrefix returns the tree prefix for a key based on its position.
208-func getKeyPrefix(isLastAttachment, isLastKey bool) string {
209- if isLastAttachment {
210- // Parent is last, use space continuation
211- if isLastKey {
212- return " " + styles.IconTreeLast + styles.IconTreeLine
213- }
214- return " " + styles.IconTreeBranch + styles.IconTreeLine
215- }
216- // Parent has siblings, use vertical line continuation
217- if isLastKey {
218- return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine
219- }
220- return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
221-}
222-
223134 // newSecretsSetCmd creates the secrets set command.
224135 func newSecretsSetCmd() *cobra.Command {
225- var attachment, fromFile string
136+ var fromFile string
226137 var jsonOut bool
227138
228139 cmd := &cobra.Command{
229140 Use: "set <domain> --from-file <path>",
230- Short: "Set secrets for a domain or attachment",
231- Long: `Set one or more secrets for a domain or an attachment container.
141+ Short: "Set installation secrets for a domain",
142+ Long: `Set one or more installation secrets for a domain.
232143
233144 Secrets are read from a mode 0600 file containing one KEY=value pair per line.
234-
235-Use --attachment to target an attachment service (e.g., postgres, redis) instead
236-of the main domain container.
145+For app secret values, use gordon apps secrets instead.
237146
238147 Examples:
239- gordon secrets set app.mydomain.com --from-file ./app.env
240- gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
148+ gordon secrets set app.mydomain.com --from-file ./app.env`,
241149 Args: cobra.ExactArgs(1),
242150 RunE: func(cmd *cobra.Command, args []string) error {
243151 ctx := cmd.Context()
@@ -256,29 +164,20 @@ Examples:
256164 secrets[parts[0]] = parts[1]
257165 }
258166
259- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
167+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
260168 if err != nil {
261169 return err
262170 }
263171 defer handle.close()
264- if attachment != "" {
265- if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
266- return fmt.Errorf("failed to set secrets: %w", err)
267- }
268- } else {
269- if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
270- return fmt.Errorf("failed to set secrets: %w", err)
271- }
172+ if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
173+ return fmt.Errorf("failed to set secrets: %w", err)
272174 }
273175
274176 target := secretDomain
275- if attachment != "" {
276- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
277- }
278177
279178 if jsonOut {
280179 return writeJSON(cmd.OutOrStdout(), map[string]any{
281- "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
180+ "domain": secretDomain, "count": len(secrets), "updated": true,
282181 })
283182 }
284183 if len(secrets) == 1 {
@@ -290,7 +189,6 @@ Examples:
290189 },
291190 }
292191
293- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
294192 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295193 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
296194 _ = cmd.MarkFlagRequired("from-file")
@@ -300,23 +198,18 @@ Examples:
300198
301199 // newSecretsRemoveCmd creates the secrets remove command.
302200 func newSecretsRemoveCmd() *cobra.Command {
303- var (
304- force bool
305- attachment string
306- )
201+ var force bool
307202
308203 cmd := &cobra.Command{
309204 Use: "remove <domain> <key>",
310- Short: "Remove a secret",
311- Long: `Remove a secret from a domain or an attachment container.
205+ Short: "Remove an installation secret",
206+ Long: `Remove an installation secret from a domain.
312207
313-Use --attachment to target an attachment service (e.g., postgres, redis) instead
314-of the main domain container.
208+For app secret values, use gordon apps secrets delete instead.
315209
316210 Examples:
317211 gordon secrets remove app.mydomain.com OLD_API_KEY
318- gordon secrets remove app.mydomain.com OLD_API_KEY --force
319- gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
212+ gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
320213 Args: cobra.ExactArgs(2),
321214 RunE: func(cmd *cobra.Command, args []string) error {
322215 ctx := cmd.Context()
@@ -324,9 +217,6 @@ Examples:
324217 key := args[1]
325218
326219 target := secretDomain
327- if attachment != "" {
328- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
329- }
330220
331221 // Confirm unless --force
332222 if !force {
@@ -342,19 +232,13 @@ Examples:
342232 }
343233 }
344234
345- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
235+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
346236 if err != nil {
347237 return err
348238 }
349239 defer handle.close()
350- if attachment != "" {
351- if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
352- return fmt.Errorf("failed to remove secret: %w", err)
353- }
354- } else {
355- if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
356- return fmt.Errorf("failed to remove secret: %w", err)
357- }
240+ if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
241+ return fmt.Errorf("failed to remove secret: %w", err)
358242 }
359243
360244 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
@@ -363,7 +247,6 @@ Examples:
363247 }
364248
365249 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
366- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
367250
368251 return cmd
369252 }
M internal/adapters/in/cli/serve.go +3 -3
@@ -10,19 +10,19 @@ import (
1010
1111 // newServeCmd creates the serve command.
1212 func newServeCmd() *cobra.Command {
13- var configPath string
13+ var cliConfigPath string
1414
1515 cmd := &cobra.Command{
1616 Use: "serve",
1717 Short: "Start the Gordon server",
1818 Long: `Start the Gordon server, including the registry and proxy components.`,
1919 RunE: func(cmd *cobra.Command, args []string) error {
20- return app.Run(context.Background(), configPath)
20+ return app.Run(context.Background(), cliConfigPath)
2121 },
2222 }
2323
2424 // Add flags
25- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
25+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
2626
2727 return cmd
2828 }
A internal/adapters/in/cli/status.go +89 -0
@@ -0,0 +1,89 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sort"
8+
9+ "github.com/spf13/cobra"
10+
11+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
13+)
14+
15+// newStatusCmd creates the status command.
16+func newStatusCmd() *cobra.Command {
17+ return &cobra.Command{
18+ Use: "status",
19+ Short: "Show Gordon server status",
20+ RunE: func(cmd *cobra.Command, args []string) error {
21+ handle, err := resolveControlPlane(cliConfigPath)
22+ if err != nil {
23+ return err
24+ }
25+ defer handle.close()
26+
27+ return runStatusCmd(cmd.Context(), handle.plane, cmd.OutOrStdout())
28+ },
29+ }
30+}
31+
32+func runStatusCmd(ctx context.Context, plane ControlPlane, out io.Writer) error {
33+ status, err := plane.GetStatus(ctx)
34+ if err != nil {
35+ return fmt.Errorf("failed to get status: %w", err)
36+ }
37+ if status == nil {
38+ return fmt.Errorf("failed to get status: empty response")
39+ }
40+
41+ if err := cliWriteLine(out, styles.Theme.Title.Render("Gordon Status")); err != nil {
42+ return err
43+ }
44+ if err := cliWriteLine(out, ""); err != nil {
45+ return err
46+ }
47+
48+ if err := cliWriteLine(out, fmt.Sprintf("%s %s", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)); err != nil {
49+ return err
50+ }
51+ if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)); err != nil {
52+ return err
53+ }
54+ if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)); err != nil {
55+ return err
56+ }
57+ if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Apps:"), status.Apps)); err != nil {
58+ return err
59+ }
60+ if err := cliWriteLine(out, fmt.Sprintf("%s %v", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)); err != nil {
61+ return err
62+ }
63+
64+ return renderStatusFleet(out, status.ContainerStatus)
65+}
66+
67+func renderStatusFleet(out io.Writer, fleet map[string]string) error {
68+ if len(fleet) == 0 {
69+ return nil
70+ }
71+ if err := cliWriteLine(out, ""); err != nil {
72+ return err
73+ }
74+ if err := cliWriteLine(out, styles.Theme.Bold.Render("Container Status:")); err != nil {
75+ return err
76+ }
77+ names := make([]string, 0, len(fleet))
78+ for name := range fleet {
79+ names = append(names, name)
80+ }
81+ sort.Strings(names)
82+ for _, name := range names {
83+ badge := components.ContainerStatusBadge(fleet[name])
84+ if err := cliWriteLine(out, fmt.Sprintf(" %s: %s", name, badge)); err != nil {
85+ return err
86+ }
87+ }
88+ return nil
89+}
M internal/adapters/in/cli/tls.go +1 -1
@@ -33,7 +33,7 @@ func newTLSStatusCmd() *cobra.Command {
3333 Use: "status",
3434 Short: "Show public TLS certificate status",
3535 RunE: func(cmd *cobra.Command, _ []string) error {
36- handle, err := tlsResolveControlPlane(configPath)
36+ handle, err := tlsResolveControlPlane(cliConfigPath)
3737 if err != nil {
3838 return err
3939 }
M internal/adapters/in/cli/traffic_status.go +1 -1
@@ -20,7 +20,7 @@ func newTrafficStatusCmd() *cobra.Command {
2020 Short: "Show traffic entrypoint, router, and counter status",
2121 Args: cobra.NoArgs,
2222 RunE: func(cmd *cobra.Command, args []string) error {
23- handle, err := trafficResolveControlPlane(configPath)
23+ handle, err := trafficResolveControlPlane(cliConfigPath)
2424 if err != nil {
2525 return err
2626 }
M internal/adapters/in/cli/traffic_test.go +3 -24
@@ -4,7 +4,6 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7- "errors"
87 "net/http"
98 "net/http/httptest"
109 "testing"
@@ -13,6 +12,7 @@ import (
1312 "github.com/stretchr/testify/require"
1413
1514 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1616 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1717 "github.com/bnema/gordon/internal/domain"
1818 )
@@ -81,12 +81,12 @@ func TestTrafficStatusJSONOutput(t *testing.T) {
8181
8282 func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
8383 status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"}
84- cp := &trafficStatusPlane{status: status}
84+ cp := mocks.NewMockControlPlane(t)
85+ cp.EXPECT().GetTrafficStatus(context.Background()).Return(status, nil).Once()
8586
8687 var buf bytes.Buffer
8788 require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))
8889
89- assert.True(t, cp.called)
9090 assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)
9191 }
9292
@@ -109,24 +109,3 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
109109 assert.Contains(t, output, "ok")
110110 assert.Contains(t, output, "udp=3")
111111 }
112-
113-func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) {
114- _, err := (&localControlPlane{}).GetTrafficStatus(context.Background())
115- require.Error(t, err)
116- assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable))
117- assert.Contains(t, err.Error(), "in-process CLI control plane")
118- assert.Contains(t, err.Error(), "running Gordon daemon")
119- assert.Contains(t, err.Error(), "--remote")
120- assert.Contains(t, err.Error(), "GORDON_REMOTE")
121-}
122-
123-type trafficStatusPlane struct {
124- ControlPlane
125- status *dto.TrafficStatusResponse
126- called bool
127-}
128-
129-func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) {
130- p.called = true
131- return p.status, nil
132-}
M internal/adapters/in/cli/ui_adoption_test.go +3 -127
@@ -3,31 +3,16 @@ package cli
33 import (
44 "bytes"
55 "context"
6- "go/ast"
7- "go/parser"
8- "go/token"
96 "io"
10- "strings"
117 "sync"
128 "testing"
139
10+ "github.com/stretchr/testify/mock"
11+
1412 "github.com/bnema/gordon/internal/adapters/dto"
1513 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1614 )
1715
18-var uiAdoptionHelperCalls = map[string]struct{}{
19- "cliWriteLine": {},
20- "cliWritef": {},
21- "cliRenderTitle": {},
22- "cliRenderMuted": {},
23- "cliRenderEmptyState": {},
24- "cliRenderListItem": {},
25- "cliRenderMeta": {},
26- "cliRenderSuccess": {},
27- "cliRenderWarning": {},
28- "cliRenderInfo": {},
29-}
30-
3116 // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
3217 // cliWritef variables. Any test that overrides these seams must hold this
3318 // mutex for the duration of the override and restore the originals on cleanup.
@@ -54,102 +39,6 @@ func TestPresentationHelpers(t *testing.T) {
5439 }
5540 }
5641
57-func TestUIAdoption(t *testing.T) {
58- for i := range uiAdoptionExpectations {
59- expect := uiAdoptionExpectations[i]
60- t.Run(expect.family, func(t *testing.T) {
61- fset := token.NewFileSet()
62- fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
63- if err != nil {
64- t.Fatalf("failed to parse %s: %v", expect.file, err)
65- }
66-
67- for _, fnName := range expect.functions {
68- t.Run(fnName, func(t *testing.T) {
69- fn := findFuncDecl(fileNode, fnName)
70- if fn == nil {
71- t.Fatalf("function %s not found in %s", fnName, expect.file)
72- }
73-
74- hasHelperCall := false
75- hasSharedUIUsage := false
76- hasForbiddenRawPrint := false
77-
78- ast.Inspect(fn.Body, func(n ast.Node) bool {
79- call, ok := n.(*ast.CallExpr)
80- if !ok {
81- return true
82- }
83-
84- switch fun := call.Fun.(type) {
85- case *ast.Ident:
86- if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
87- hasHelperCall = true
88- }
89- case *ast.SelectorExpr:
90- if isForbiddenRawPrintCall(call, fun) {
91- hasForbiddenRawPrint = true
92- }
93- pkgIdent, ok := fun.X.(*ast.Ident)
94- if !ok {
95- return true
96- }
97- if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
98- hasSharedUIUsage = true
99- }
100- }
101-
102- return true
103- })
104-
105- if !hasHelperCall && !hasSharedUIUsage {
106- t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
107- }
108-
109- if hasForbiddenRawPrint {
110- t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
111- }
112- })
113- }
114- })
115- }
116-}
117-
118-func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
119- pkgIdent, ok := sel.X.(*ast.Ident)
120- if !ok {
121- return false
122- }
123-
124- if pkgIdent.Name == "fmt" {
125- switch sel.Sel.Name {
126- case "Print", "Printf", "Println":
127- return true
128- case "Fprint", "Fprintf", "Fprintln":
129- if len(call.Args) == 0 {
130- return true
131- }
132-
133- switch dst := call.Args[0].(type) {
134- case *ast.SelectorExpr:
135- if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") {
136- return true
137- }
138- case *ast.Ident:
139- if dst.Name == "out" {
140- return true
141- }
142- }
143- }
144- }
145-
146- if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
147- return true
148- }
149-
150- return false
151-}
152-
15342 func TestUIAdoptionRuntimeSeams(t *testing.T) {
15443 uiAdoptionSeamMu.Lock()
15544 defer uiAdoptionSeamMu.Unlock()
@@ -190,7 +79,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
19079 }
19180
19281 pruneMock := climocks.NewMockimagesClient(t)
193- pruneMock.EXPECT().ListImages(context.Background()).Return([]dto.Image{}, nil).Once()
82+ pruneMock.EXPECT().PruneImages(context.Background(), mock.Anything).Return(&dto.ImagePruneResponse{Plan: dto.PruneSummary{}}, nil).Once()
19483
19584 pruneBuf := new(bytes.Buffer)
19685 if err := runImagesPrune(context.Background(), pruneMock, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
@@ -204,16 +93,3 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
20493 t.Fatal("expected cliWritef to be called")
20594 }
20695 }
207-
208-func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
209- for _, decl := range file.Decls {
210- fn, ok := decl.(*ast.FuncDecl)
211- if !ok {
212- continue
213- }
214- if fn.Name.Name == name {
215- return fn
216- }
217- }
218- return nil
219-}
M internal/adapters/in/cli/volumes.go +40 -26
@@ -40,7 +40,7 @@ func newVolumesListCmd() *cobra.Command {
4040 Use: "list",
4141 Short: "List all volumes",
4242 RunE: func(cmd *cobra.Command, _ []string) error {
43- handle, err := resolveControlPlane(configPath)
43+ handle, err := resolveControlPlane(cliConfigPath)
4444 if err != nil {
4545 return err
4646 }
@@ -56,9 +56,14 @@ func newVolumesPruneCmd() *cobra.Command {
5656 var opts volumesPruneOptions
5757 cmd := &cobra.Command{
5858 Use: "prune",
59- Short: "Remove orphaned volumes",
59+ Short: "Remove released app-owned volumes",
60+ Long: `Remove app-owned volumes that were explicitly released and are unused.
61+
62+Only a volume whose durable ownership record marks it released, whose runtime
63+labels agree with that record, and which no container mounts is removed.
64+Attached, retained, legacy-managed, unmanaged, and unknown volumes survive.`,
6065 RunE: func(cmd *cobra.Command, _ []string) error {
61- handle, err := resolveControlPlane(configPath)
66+ handle, err := resolveControlPlane(cliConfigPath)
6267 if err != nil {
6368 return err
6469 }
@@ -66,7 +71,7 @@ func newVolumesPruneCmd() *cobra.Command {
6671 return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())
6772 },
6873 }
69- cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed")
74+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "report the plan without deleting")
7075 cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")
7176 cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")
7277 return cmd
@@ -133,33 +138,27 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
133138 return err
134139 }
135140
136- if preview.VolumesRemoved == 0 {
137- if opts.Json {
138- return writeJSON(out, preview)
139- }
140- return cliWriteLine(out, "No orphaned volumes to remove.")
141- }
142-
143141 if opts.DryRun {
144- if opts.Json {
145- return writeJSON(out, preview)
146- }
147- return renderPrunePreview(out, preview)
142+ return renderVolumePrunePlan(out, opts.Json, preview)
143+ }
144+ if preview.Plan.Eligible == 0 {
145+ // A zero-eligibility plan is a normal outcome: the report explains
146+ // why each volume survived.
147+ return renderVolumePrunePlan(out, opts.Json, preview)
148148 }
149149
150150 if !opts.Json {
151151 if err := renderPrunePreview(out, preview); err != nil {
152152 return err
153153 }
154- }
155-
156- if !opts.NoConfirm && !opts.Json {
157- confirmed, err := components.RunConfirm("Remove these volumes?")
158- if err != nil {
159- return err
160- }
161- if !confirmed {
162- return cliWriteLine(out, "Cancelled.")
154+ if !opts.NoConfirm {
155+ confirmed, err := components.RunConfirm("Remove these volumes?")
156+ if err != nil {
157+ return err
158+ }
159+ if !confirmed {
160+ return cliWriteLine(out, "Cancelled.")
161+ }
163162 }
164163 }
165164
@@ -167,10 +166,25 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
167166 if err != nil {
168167 return err
169168 }
170-
171169 if opts.Json {
172170 return writeJSON(out, result)
173171 }
174172
175- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed))))
173+ if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))); err != nil {
174+ return err
175+ }
176+ return cliRenderPruneSummary(out, result.Plan)
177+}
178+
179+// renderVolumePrunePlan writes one plan without deleting anything.
180+func renderVolumePrunePlan(out io.Writer, asJSON bool, resp *dto.VolumePruneResponse) error {
181+ if asJSON {
182+ return writeJSON(out, resp)
183+ }
184+ if resp.Plan.Eligible == 0 {
185+ if err := cliWriteLine(out, "No released volumes to remove."); err != nil {
186+ return err
187+ }
188+ }
189+ return cliRenderPruneSummary(out, resp.Plan)
176190 }
M internal/adapters/in/cli/volumes_test.go +26 -9 collapsed to keep the page light
D internal/adapters/in/http/admin/bootstrap_test.go +0 -413 collapsed to keep the page light
M internal/adapters/in/http/admin/handler.go +103 -1364 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps.go +603 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps_test.go +379 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired.go +48 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired_test.go +60 -0 collapsed to keep the page light
M internal/adapters/in/http/admin/handler_test.go +240 -1929 large diff (2169 lines changed): collapsed
M internal/adapters/in/http/admin/handler_volumes.go +6 -0
@@ -2,6 +2,7 @@ package admin
22
33 import (
44 "encoding/json"
5+ "errors"
56 "net/http"
67
78 "github.com/bnema/zerowrap"
@@ -84,6 +85,10 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
8485 if err != nil {
8586 log := zerowrap.FromCtx(ctx)
8687 log.Error().Err(err).Msg("failed to prune volumes")
88+ if errors.Is(err, domain.ErrPruneDisabled) {
89+ h.sendJSON(w, http.StatusConflict, dto.AppError{Error: "prune-disabled", Message: err.Error()})
90+ return
91+ }
8792 h.sendError(w, http.StatusInternalServerError, "failed to prune volumes")
8893 return
8994 }
@@ -100,5 +105,6 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
100105 VolumesRemoved: report.VolumesRemoved,
101106 SpaceReclaimed: report.SpaceReclaimed,
102107 Volumes: vols,
108+ Plan: toPruneSummary(report.Plan),
103109 })
104110 }
M internal/adapters/in/http/admin/images.go +8 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/local_authority.go +154 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/local_authority_test.go +148 -0 collapsed to keep the page light
D internal/adapters/in/http/admin/preview_handler.go +0 -167 collapsed to keep the page light
A internal/adapters/in/http/admin/prune.go +11 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/prune_test.go +112 -0 collapsed to keep the page light
M internal/adapters/in/http/httphelper/localhost.go +6 -5 collapsed to keep the page light
M internal/adapters/in/http/middleware/auth.go +13 -33 collapsed to keep the page light
M internal/adapters/in/http/middleware/auth_test.go +50 -3 collapsed to keep the page light
M internal/adapters/in/http/middleware/cidr.go +92 -14 collapsed to keep the page light
M internal/adapters/in/http/middleware/cidr_test.go +134 -14 collapsed to keep the page light
M internal/adapters/in/http/onboarding/handler_test.go +9 -9 collapsed to keep the page light
M internal/adapters/in/http/proxy/handler.go +26 -2 collapsed to keep the page light
M internal/adapters/in/http/proxy/handler_test.go +20 -1 collapsed to keep the page light
M internal/adapters/in/http/registry/handler.go +62 -130 collapsed to keep the page light
M internal/adapters/in/http/registry/handler_test.go +29 -5 collapsed to keep the page light
A internal/adapters/in/http/registry/route/route.go +227 -0 collapsed to keep the page light
A internal/adapters/in/http/registry/route/route_test.go +125 -0 collapsed to keep the page light
A internal/adapters/in/traffic/l4_roundtrip_test.go +63 -0 collapsed to keep the page light
A internal/adapters/localadmin/localadmin.go +412 -0 collapsed to keep the page light
A internal/adapters/localadmin/localadmin_test.go +397 -0 collapsed to keep the page light
A internal/adapters/localadmin/peer_linux.go +80 -0 collapsed to keep the page light
A internal/adapters/localadmin/peer_other.go +20 -0 collapsed to keep the page light
A internal/adapters/out/appsecrets/store.go +81 -0 collapsed to keep the page light
A internal/adapters/out/appsecrets/store_test.go +23 -0 collapsed to keep the page light
A internal/adapters/out/appstate/export_test.go +63 -0 collapsed to keep the page light
A internal/adapters/out/appstate/protection.go +429 -0 collapsed to keep the page light
A internal/adapters/out/appstate/protection_test.go +378 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store.go +1637 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_claim_test.go +211 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_coordination_test.go +74 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_retire_test.go +134 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_test.go +523 -0 collapsed to keep the page light
A internal/adapters/out/docker/live_runtime_test.go +206 -0 collapsed to keep the page light
M internal/adapters/out/docker/runtime.go +133 -81 collapsed to keep the page light
A internal/adapters/out/docker/runtime_backend_binds_test.go +128 -0 collapsed to keep the page light
M internal/adapters/out/docker/runtime_create_test.go +48 -0 collapsed to keep the page light
D internal/adapters/out/docker/runtime_image_cleanup_test.go +0 -190 collapsed to keep the page light
A internal/adapters/out/docker/runtime_prune.go +202 -0 collapsed to keep the page light
A internal/adapters/out/docker/runtime_prune_test.go +214 -0 collapsed to keep the page light
A internal/adapters/out/docker/runtime_recovery_test.go +148 -0 collapsed to keep the page light
A internal/adapters/out/docker/stop_timeout_test.go +42 -0 collapsed to keep the page light
A internal/adapters/out/docker/testhelpers_test.go +23 -0 collapsed to keep the page light
M internal/adapters/out/filesystem/backup_storage.go +23 -17 collapsed to keep the page light
M internal/adapters/out/filesystem/backup_storage_test.go +49 -10 collapsed to keep the page light
M internal/adapters/out/filesystem/blob.go +121 -5 collapsed to keep the page light
M internal/adapters/out/filesystem/blob_test.go +80 -11 collapsed to keep the page light
M internal/adapters/out/filesystem/manifest.go +34 -0 collapsed to keep the page light
M internal/adapters/out/filesystem/manifest_test.go +20 -0 collapsed to keep the page light
D internal/adapters/out/filesystem/previewstore.go +0 -92 collapsed to keep the page light
D internal/adapters/out/filesystem/previewstore_test.go +0 -61 collapsed to keep the page light
A internal/adapters/out/imageref/resolver.go +114 -0 collapsed to keep the page light
A internal/adapters/out/imageref/resolver_test.go +135 -0 collapsed to keep the page light
M internal/adapters/out/s3/volume_backup_storage.go +24 -19 collapsed to keep the page light
M internal/adapters/out/s3/volume_backup_storage_test.go +10 -8 collapsed to keep the page light
A internal/app/app_monitor.go +92 -0 collapsed to keep the page light
A internal/app/app_monitor_test.go +403 -0 collapsed to keep the page light
A internal/app/app_traffic.go +189 -0 collapsed to keep the page light
A internal/app/app_traffic_publication_test.go +180 -0 collapsed to keep the page light
A internal/app/gcbarrier.go +109 -0 collapsed to keep the page light
A internal/app/gcbarrier_test.go +238 -0 collapsed to keep the page light
M internal/app/kernel.go +4 -0
@@ -28,6 +28,7 @@ type Kernel struct {
2828 logSvc in.LogService
2929 volumeSvc in.VolumeService
3030 publicTLSSvc in.PublicTLSService
31+ appSvc in.AppService
3132 cleanup func()
3233 }
3334
@@ -90,6 +91,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
9091 logSvc: svc.logSvc,
9192 volumeSvc: svc.volumeSvc,
9293 publicTLSSvc: svc.publicTLSSvc,
94+ appSvc: svc.appSvc,
9395 cleanup: wrappedCleanup,
9496 }, nil
9597 } else {
@@ -150,4 +152,6 @@ func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc }
150152
151153 func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc }
152154
155+func (k *Kernel) Apps() in.AppService { return k.appSvc }
156+
153157 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
A internal/app/local_admin_socket.go +104 -0 collapsed to keep the page light
A internal/app/local_admin_socket_test.go +142 -0 collapsed to keep the page light
M internal/app/pki_integration_test.go +15 -11 collapsed to keep the page light
A internal/app/prune_scheduler_test.go +50 -0 collapsed to keep the page light
M internal/app/public_tls_wiring_test.go +22 -5 collapsed to keep the page light
M internal/app/run.go +440 -475 collapsed to keep the page light
M internal/app/run_entrypoint_migration_test.go +10 -0 collapsed to keep the page light
M internal/app/run_http_handlers_test.go +32 -26 collapsed to keep the page light
A internal/app/run_registry_endpoints_test.go +26 -0 collapsed to keep the page light
M internal/app/run_reload_test.go +34 -116 collapsed to keep the page light
M internal/app/start_proxy_servers_test.go +4 -11 collapsed to keep the page light
D internal/app/startup_recovery.go +0 -44 collapsed to keep the page light
D internal/app/startup_recovery_test.go +0 -119 collapsed to keep the page light
M internal/app/traffic.go +100 -2 collapsed to keep the page light
M internal/app/traffic_test.go +118 -6 collapsed to keep the page light
A internal/boundaries/in/app_reconciler.go +17 -0 collapsed to keep the page light
A internal/boundaries/in/apps.go +137 -0 collapsed to keep the page light
M internal/boundaries/in/backup.go +22 -11 collapsed to keep the page light
M internal/boundaries/in/config.go +5 -55 collapsed to keep the page light
M internal/boundaries/in/container.go +5 -50 collapsed to keep the page light
D internal/boundaries/in/deploy.go +0 -10 collapsed to keep the page light
M internal/boundaries/in/health.go +3 -7 collapsed to keep the page light
A internal/boundaries/in/mocks/mock_app_reconciler.go +89 -0 collapsed to keep the page light
A internal/boundaries/in/mocks/mock_app_service.go +924 -0 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_backup_service.go +64 -126 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_config_service.go +147 -1300 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_container_service.go +20 -909 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_health_service.go +0 -59 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_registry_service.go +28 -16 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_secret_service.go +0 -215 collapsed to keep the page light
D internal/boundaries/in/mocks/mock_standalone_service_service.go +0 -158 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_volume_backup_service.go +30 -24 collapsed to keep the page light
M internal/boundaries/in/registry.go +2 -2 collapsed to keep the page light
M internal/boundaries/in/secrets.go +0 -14 collapsed to keep the page light
D internal/boundaries/in/services.go +0 -16 collapsed to keep the page light
A internal/boundaries/out/app_hosts.go +23 -0 collapsed to keep the page light
A internal/boundaries/out/app_state.go +172 -0 collapsed to keep the page light
D internal/boundaries/out/attachment_config.go +0 -25 collapsed to keep the page light
M internal/boundaries/out/backupstore.go +3 -3 collapsed to keep the page light
A internal/boundaries/out/image_resolver.go +15 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_state.go +1939 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_state_reader.go +239 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_traffic_refresher.go +215 -0 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_attachment_config_provider.go +0 -127 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_backup_storage.go +48 -42 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_blob_storage.go +154 -73 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_container_runtime.go +197 -96 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_gc_barrier.go +163 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_gc_lease.go +69 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_image_resolver.go +104 -0 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_proxy_cache_invalidator.go +0 -80 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_prune_protection_store.go +101 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_prune_runtime.go +215 -0 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_route_checker.go +0 -138 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_secret_writer.go +158 -0 collapsed to keep the page light
M internal/boundaries/out/pki.go +7 -6 collapsed to keep the page light
A internal/boundaries/out/ports_test.go +166 -0 collapsed to keep the page light
D internal/boundaries/out/previewstore.go +0 -13 collapsed to keep the page light
D internal/boundaries/out/proxy_cache.go +0 -19 collapsed to keep the page light
A internal/boundaries/out/prune.go +69 -0 collapsed to keep the page light
M internal/boundaries/out/runtime.go +21 -4 collapsed to keep the page light
M internal/boundaries/out/secrets.go +11 -0 collapsed to keep the page light
M internal/boundaries/out/storage.go +13 -5 collapsed to keep the page light
A internal/domain/app_l4.go +96 -0 collapsed to keep the page light
A internal/domain/app_l4_test.go +77 -0 collapsed to keep the page light
A internal/domain/app_network.go +120 -0 collapsed to keep the page light
A internal/domain/app_network_test.go +75 -0 collapsed to keep the page light
A internal/domain/app_readiness_test.go +46 -0 collapsed to keep the page light
A internal/domain/app_reservations.go +137 -0 collapsed to keep the page light
A internal/domain/app_reservations_test.go +83 -0 collapsed to keep the page light
A internal/domain/app_spec.go +807 -0 collapsed to keep the page light
A internal/domain/app_spec_test.go +158 -0 collapsed to keep the page light
A internal/domain/app_state.go +337 -0 collapsed to keep the page light
M internal/domain/auth.go +8 -1 collapsed to keep the page light
D internal/domain/auto.go +0 -24 collapsed to keep the page light
M internal/domain/backup.go +39 -55 collapsed to keep the page light
D internal/domain/backup_test.go +0 -24 collapsed to keep the page light
M internal/domain/container.go +20 -0 collapsed to keep the page light
M internal/domain/errors.go +31 -0 collapsed to keep the page light
M internal/domain/event.go +4 -40 collapsed to keep the page light
D internal/domain/event_test.go +0 -58 collapsed to keep the page light
A internal/domain/image_policy.go +195 -0 collapsed to keep the page light
A internal/domain/image_policy_test.go +106 -0 collapsed to keep the page light
A internal/domain/image_ref_repo_test.go +47 -0 collapsed to keep the page light
M internal/domain/images.go +5 -0 collapsed to keep the page light
M internal/domain/labels.go +19 -7 collapsed to keep the page light
M internal/domain/labels_test.go +0 -3 collapsed to keep the page light
D internal/domain/preview.go +0 -36 collapsed to keep the page light
D internal/domain/preview_test.go +0 -27 collapsed to keep the page light
A internal/domain/prune.go +1378 -0 collapsed to keep the page light
A internal/domain/prune_test.go +379 -0 collapsed to keep the page light
M internal/domain/volumes.go +3 -0 collapsed to keep the page light
M internal/testutils/mocks/runtime_gen.go +30 -29 collapsed to keep the page light
A internal/usecase/apps/app_read_model_test.go +191 -0 collapsed to keep the page light
A internal/usecase/apps/app_service.go +473 -0 collapsed to keep the page light
A internal/usecase/apps/app_service_test.go +90 -0 collapsed to keep the page light
A internal/usecase/apps/mocks_test.go +77 -0 collapsed to keep the page light
A internal/usecase/apps/service.go +301 -0 collapsed to keep the page light
A internal/usecase/apps/service_test.go +351 -0 collapsed to keep the page light
A internal/usecase/apptraffic/activate.go +211 -0 collapsed to keep the page light
A internal/usecase/apptraffic/activate_test.go +85 -0 collapsed to keep the page light
A internal/usecase/apptraffic/projection.go +173 -0 collapsed to keep the page light
A internal/usecase/apptraffic/projection_test.go +225 -0 collapsed to keep the page light
D internal/usecase/auto/dispatcher.go +0 -77 collapsed to keep the page light
D internal/usecase/auto/dispatcher_test.go +0 -94 collapsed to keep the page light
D internal/usecase/auto/labels.go +0 -124 collapsed to keep the page light
D internal/usecase/auto/labels_test.go +0 -277 collapsed to keep the page light
D internal/usecase/auto/preview/handler.go +0 -157 collapsed to keep the page light
D internal/usecase/auto/preview/handler_test.go +0 -147 collapsed to keep the page light
D internal/usecase/auto/preview/integration_test.go +0 -240 collapsed to keep the page light
D internal/usecase/auto/preview/naming.go +0 -75 collapsed to keep the page light
D internal/usecase/auto/preview/naming_test.go +0 -80 collapsed to keep the page light
D internal/usecase/auto/preview/orphans.go +0 -80 collapsed to keep the page light
D internal/usecase/auto/preview/orphans_test.go +0 -165 collapsed to keep the page light
D internal/usecase/auto/preview/service.go +0 -477 collapsed to keep the page light
D internal/usecase/auto/preview/service_test.go +0 -215 collapsed to keep the page light
D internal/usecase/auto/preview/ticker_test.go +0 -31 collapsed to keep the page light
D internal/usecase/auto/preview/volumes.go +0 -104 collapsed to keep the page light
D internal/usecase/auto/preview/volumes_test.go +0 -18 collapsed to keep the page light
D internal/usecase/auto/validation.go +0 -48 collapsed to keep the page light
D internal/usecase/auto/validation_test.go +0 -75 collapsed to keep the page light
D internal/usecase/backup/detector.go +0 -89 collapsed to keep the page light
D internal/usecase/backup/detector_test.go +0 -180 collapsed to keep the page light
M internal/usecase/backup/integration_test.go +29 -95 collapsed to keep the page light
M internal/usecase/backup/service.go +188 -156 collapsed to keep the page light
M internal/usecase/backup/service_test.go +188 -193 collapsed to keep the page light
A internal/usecase/backup/targets.go +172 -0 collapsed to keep the page light
M internal/usecase/backup/volume_service.go +128 -120 collapsed to keep the page light
M internal/usecase/backup/volume_service_test.go +161 -86 collapsed to keep the page light
D internal/usecase/backup/volume_targets.go +0 -115 collapsed to keep the page light
D internal/usecase/backup/volume_targets_test.go +0 -115 collapsed to keep the page light
M internal/usecase/config/service.go +87 -1360 collapsed to keep the page light
M internal/usecase/config/service_test.go +98 -2707 collapsed to keep the page light
D internal/usecase/container/autoroute.go +0 -473 collapsed to keep the page light
D internal/usecase/container/autoroute_test.go +0 -1322 collapsed to keep the page light
D internal/usecase/container/config_provider.go +0 -7 collapsed to keep the page light
D internal/usecase/container/env_hash.go +0 -23 collapsed to keep the page light
D internal/usecase/container/env_hash_test.go +0 -59 collapsed to keep the page light
D internal/usecase/container/events.go +0 -248 collapsed to keep the page light
D internal/usecase/container/events_test.go +0 -546 collapsed to keep the page light
D internal/usecase/container/monitor.go +0 -265 collapsed to keep the page light
D internal/usecase/container/monitor_test.go +0 -347 collapsed to keep the page light
D internal/usecase/container/readiness.go +0 -170 collapsed to keep the page light
D internal/usecase/container/readiness_test.go +0 -167 collapsed to keep the page light
D internal/usecase/container/routes_test.go +0 -173 collapsed to keep the page light
D internal/usecase/container/secrets_handler.go +0 -155 collapsed to keep the page light
D internal/usecase/container/secrets_handler_test.go +0 -214 collapsed to keep the page light
M internal/usecase/container/service.go +48 -3770 collapsed to keep the page light
D internal/usecase/container/service_poll_test.go +0 -63 collapsed to keep the page light
D internal/usecase/container/service_readiness_test.go +0 -314 collapsed to keep the page light
M internal/usecase/container/service_test.go +13 -5219 collapsed to keep the page light
A internal/usecase/deployment/coordinator.go +60 -0 collapsed to keep the page light
A internal/usecase/deployment/coordinator_internal_test.go +379 -0 collapsed to keep the page light
A internal/usecase/deployment/deploy_mock_test.go +762 -0 collapsed to keep the page light
A internal/usecase/deployment/execute.go +1126 -0 collapsed to keep the page light
A internal/usecase/deployment/execute_internal_test.go +22 -0 collapsed to keep the page light
A internal/usecase/deployment/gcbarrier_test.go +51 -0 collapsed to keep the page light
A internal/usecase/deployment/http_cutover_behavior_test.go +143 -0 collapsed to keep the page light
A internal/usecase/deployment/image_policy_internal_test.go +75 -0 collapsed to keep the page light
A internal/usecase/deployment/lifecycle.go +740 -0 collapsed to keep the page light
A internal/usecase/deployment/monitor.go +821 -0 collapsed to keep the page light
A internal/usecase/deployment/network_reclaim_internal_test.go +172 -0 collapsed to keep the page light
A internal/usecase/deployment/networks.go +226 -0 collapsed to keep the page light
A internal/usecase/deployment/networks_internal_test.go +181 -0 collapsed to keep the page light
A internal/usecase/deployment/operation_claim_integration_test.go +97 -0 collapsed to keep the page light
A internal/usecase/deployment/operation_claim_test.go +271 -0 collapsed to keep the page light
A internal/usecase/deployment/probes.go +63 -0 collapsed to keep the page light
A internal/usecase/deployment/publication_internal_test.go +159 -0 collapsed to keep the page light
A internal/usecase/deployment/readiness.go +260 -0 collapsed to keep the page light
A internal/usecase/deployment/readiness_internal_test.go +48 -0 collapsed to keep the page light
A internal/usecase/deployment/reconcile_internal_test.go +127 -0 collapsed to keep the page light
A internal/usecase/deployment/reconcile_mock_test.go +806 -0 collapsed to keep the page light
A internal/usecase/deployment/recovery_integration_test.go +428 -0 collapsed to keep the page light
A internal/usecase/deployment/retirement.go +168 -0 collapsed to keep the page light
A internal/usecase/deployment/retirement_internal_test.go +70 -0 collapsed to keep the page light
A internal/usecase/deployment/retirement_test.go +205 -0 collapsed to keep the page light
A internal/usecase/deployment/service.go +671 -0 collapsed to keep the page light
A internal/usecase/deployment/service_test.go +397 -0 collapsed to keep the page light
A internal/usecase/deployment/traffic_failure_test.go +132 -0 collapsed to keep the page light
M internal/usecase/health/service.go +108 -82 collapsed to keep the page light
M internal/usecase/health/service_test.go +72 -185 collapsed to keep the page light
A internal/usecase/images/prune.go +692 -0 collapsed to keep the page light
A internal/usecase/images/prune_failclosed_test.go +428 -0 collapsed to keep the page light
A internal/usecase/images/prune_test.go +483 -0 collapsed to keep the page light
M internal/usecase/images/service.go +5 -346 collapsed to keep the page light
M internal/usecase/images/service_test.go +14 -515 collapsed to keep the page light
M internal/usecase/logs/service.go +45 -15 collapsed to keep the page light
M internal/usecase/logs/service_test.go +63 -45 collapsed to keep the page light
M internal/usecase/pki/service.go +9 -9 collapsed to keep the page light
M internal/usecase/pki/service_test.go +39 -10 collapsed to keep the page light
M internal/usecase/proxy/events.go +0 -54 collapsed to keep the page light
M internal/usecase/proxy/service.go +101 -127 collapsed to keep the page light
M internal/usecase/proxy/service_test.go +142 -392 collapsed to keep the page light
D internal/usecase/proxy/target_metadata.go +0 -74 collapsed to keep the page light
D internal/usecase/proxy/target_metadata_test.go +0 -137 collapsed to keep the page light
A internal/usecase/pruneguard/guard.go +103 -0 collapsed to keep the page light
A internal/usecase/pruneguard/plan_test.go +617 -0 collapsed to keep the page light
A internal/usecase/pruneguard/registry.go +144 -0 collapsed to keep the page light
A internal/usecase/pruneguard/runtime.go +263 -0 collapsed to keep the page light
A internal/usecase/pruneguard/volume.go +111 -0 collapsed to keep the page light
M internal/usecase/publictls/integration_test.go +8 -8 collapsed to keep the page light
M internal/usecase/publictls/renewal_test.go +1 -1 collapsed to keep the page light
M internal/usecase/publictls/service.go +7 -9 collapsed to keep the page light
M internal/usecase/publictls/service_test.go +47 -40 collapsed to keep the page light
M internal/usecase/publictls/status.go +6 -2 collapsed to keep the page light
M internal/usecase/publictls/targets.go +10 -6 collapsed to keep the page light
M internal/usecase/publictls/targets_test.go +24 -12 collapsed to keep the page light
M internal/usecase/registry/service.go +64 -168 collapsed to keep the page light
M internal/usecase/registry/service_test.go +77 -60 collapsed to keep the page light
M internal/usecase/secrets/service.go +0 -151 collapsed to keep the page light
M internal/usecase/secrets/service_test.go +0 -227 collapsed to keep the page light
D internal/usecase/services/service.go +0 -640 collapsed to keep the page light
D internal/usecase/services/service_test.go +0 -482 collapsed to keep the page light
M internal/usecase/volumes/service.go +91 -21 collapsed to keep the page light
M internal/usecase/volumes/service_test.go +173 -80 collapsed to keep the page light
M pkg/registrypush/push.go +13 -2 collapsed to keep the page light
M pkg/registrypush/push_test.go +18 -0 collapsed to keep the page light
M pkg/runtime/interface.go +4 -14 collapsed to keep the page light
M pkg/validation/image.go +21 -1 collapsed to keep the page light
M pkg/validation/image_test.go +18 -0 collapsed to keep the page light
A tests/install_next_test.sh +254 -0 collapsed to keep the page light
M wiki/agents/deploy.md +32 -82 collapsed to keep the page light
M wiki/examples/production.md +4 -22 collapsed to keep the page light
M wiki/guides/remote-cli.md +34 -480 collapsed to keep the page light
M wiki/guides/secure-vps-setup.md +1 -1 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close