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:
13 pull-requests: read13 pull-requests: read
14 outputs:14 outputs:
15 go: ${{ steps.filter.outputs.go }}15 go: ${{ steps.filter.outputs.go }}
16+ installer: ${{ steps.filter.outputs.installer }}
16 steps:17 steps:
17 - uses: actions/checkout@v618 - uses: actions/checkout@v6
18 - uses: dorny/paths-filter@v319 - uses: dorny/paths-filter@v3
@@ -26,6 +27,22 @@ jobs:
26 - '.golangci.yml'27 - '.golangci.yml'
27 - 'Makefile'28 - 'Makefile'
28 - '.github/workflows/**'29 - '.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
29 46
30 lint:47 lint:
31 name: Lint48 name: Lint
@@ -38,14 +55,7 @@ jobs:
38 - uses: actions/setup-go@v555 - uses: actions/setup-go@v5
39 with:56 with:
40 go-version: "1.27.x"57 go-version: "1.27.x"
41-58+ cache: true
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-
49 59
50 - name: Run golangci-lint60 - name: Run golangci-lint
51 uses: golangci/golangci-lint-action@v961 uses: golangci/golangci-lint-action@v9
@@ -63,22 +73,7 @@ jobs:
63 - uses: actions/setup-go@v573 - uses: actions/setup-go@v5
64 with:74 with:
65 go-version: "1.27.x"75 go-version: "1.27.x"
66-76+ cache: true
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-
82 77
83 - name: Run tests78 - name: Run tests
84 run: go test -race -v ./...79 run: go test -race -v ./...
M .mockery.yaml +11 -3
@@ -19,18 +19,25 @@ packages:
19 SecretProvider:19 SecretProvider:
20 EnvLoader:20 EnvLoader:
21 ContainerLogWriter:21 ContainerLogWriter:
22- AttachmentConfigProvider:
23 TokenStore:22 TokenStore:
23+ AppState:
24+ AppStateReader:
25+ ImageResolver:
26+ SecretWriter:
24 DomainSecretStore:27 DomainSecretStore:
25 RateLimiter:28 RateLimiter:
26 BackupStorage:29 BackupStorage:
27- RouteChecker:
28 HTTPChallengeSink:30 HTTPChallengeSink:
29 PublicCertificateIssuer:31 PublicCertificateIssuer:
30 CertificateStore:32 CertificateStore:
31 SecretResolver:33 SecretResolver:
32 CloudflareZoneResolver:34 CloudflareZoneResolver:
33 CertificateAuthority:35 CertificateAuthority:
36+ AppTrafficRefresher:
37+ PruneProtectionStore:
38+ PruneRuntime:
39+ GCBarrier:
40+ GCLease:
34 github.com/bnema/gordon/internal/boundaries/in:41 github.com/bnema/gordon/internal/boundaries/in:
35 interfaces:42 interfaces:
36 ContainerService:43 ContainerService:
@@ -47,7 +54,8 @@ packages:
47 VolumeService:54 VolumeService:
48 PublicTLSService:55 PublicTLSService:
49 TrafficStatusService:56 TrafficStatusService:
50- StandaloneServiceService:57+ AppService:
58+ AppReconciler:
51 # Exception: pushImageOps is a CLI-local interface, not a boundary port.59 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
52 # Mocked here because it abstracts Docker SDK calls that require a running60 # Mocked here because it abstracts Docker SDK calls that require a running
53 # daemon, making unit/integration tests impractical without a test double.61 # daemon, making unit/integration tests impractical without a test double.
M README.md +73 -45
@@ -2,7 +2,7 @@
2 2
3 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)3 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4 4
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.
6 6
7 - Website: https://bnema.dev/gordon7 - Website: https://bnema.dev/gordon
8 - Documentation: [Docs](https://gordon.bnema.dev/docs) | [Wiki](https://gordon.bnema.dev/wiki)8 - 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.
12 12
13 ## What is Gordon?13 ## What is Gordon?
14 14
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.
16 18
17 ## Quick Start19 ## Quick Start
18 20
19 ```bash21 ```bash
20-# Install22+# Install the latest stable release to ~/.local/bin
21 curl -fsSL https://gordon.bnema.dev/install.sh | sh23 curl -fsSL https://gordon.bnema.dev/install.sh | sh
22 24
23-# Start the server25+# Start the server (restart your shell first if the installer updated PATH)
24 gordon serve26 gordon serve
25 ```27 ```
26 28
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+
27 Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for full setup.33 Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for full setup.
28 34
29 ## Deploy with the CLI35 ## Deploy with the CLI
30 36
31-Build locally, push directly to your Gordon server:37+Build locally, push to your Gordon server, declare the app, deploy:
32 38
33 ```bash39 ```bash
34-# Push using an explicit domain override40+# Push the image (OCI transfer only, never deploys)
35-gordon push myapp:latest --domain app.example.com41+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
36 45
37-# Or add a route manually, then deploy46+# Activate it
38-gordon routes add app.example.com myapp:latest47+gordon apps deploy blog --remote prod
39-gordon routes deploy app.example.com
40 48
41 # Check status49 # Check status
42 gordon status50 gordon status
51+gordon apps status blog --remote prod
43 ```52 ```
44 53
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:
46 69
47 ```bash70 ```bash
48-gordon pin app.example.com # Pin to a specific image tag71+gordon apps restart blog --remote prod # Restart from pinned digests
49-gordon restart app.example.com # Restart the container72+gordon apps stop blog --remote prod # Stop, preserve all data
50-gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret12373+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
51 ```74 ```
52 75
53 ## Deploy from CI/CD76 ## Deploy from CI/CD
54 77
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.
56 79
57 ### GitHub Actions80 ### GitHub Actions
58 81
@@ -64,17 +87,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on
64 password: ${{ secrets.GORDON_TOKEN }}87 password: ${{ secrets.GORDON_TOKEN }}
65 ```88 ```
66 89
67-### Docker CLI90+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
68 93
69 ```bash94 ```bash
70-docker login registry.mydomain.com95+docker login gordon.mydomain.com
71-docker build -t registry.mydomain.com/myapp:v1.0.0 .96+docker build -t gordon.mydomain.com/myapp:v1.0.0 .
72-docker push registry.mydomain.com/myapp:v1.0.097+docker push gordon.mydomain.com/myapp:v1.0.0
73-# -> Deployed automatically98+# Then, with the Gordon binary:
99+gordon apps apply --file blog.toml --remote prod
100+gordon apps deploy blog --remote prod
74 ```101 ```
75 102
76-See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
77-
78 ## CLI Commands103 ## CLI Commands
79 104
80 ### Server105 ### Server
@@ -82,20 +107,24 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
82 | Command | Description |107 | Command | Description |
83 |---------|-------------|108 |---------|-------------|
84 | `gordon serve` | Start the Gordon server |109 | `gordon serve` | Start the Gordon server |
85-| `gordon status` | Show server and route health |110+| `gordon status` | Show server and app fleet status |
86-| `gordon config show` | Display server configuration |111+| `gordon config show` | Display installation configuration |
87 112
88-### Deployment113+### Applications
89 114
90 | Command | Description |115 | Command | Description |
91 |---------|-------------|116 |---------|-------------|
92-| `gordon push [image]` | Tag, push, and optionally deploy an image |117+| `gordon apps apply --file FILE` | Validate and persist an app manifest |
93-| `gordon routes list` | List all routes |118+| `gordon apps deploy APP` | Activate an app revision |
94-| `gordon routes add <domain> <image>` | Create or update a route |119+| `gordon apps list` | List applications |
95-| `gordon routes remove <domain>` | Remove a route |120+| `gordon apps show APP` | Show desired and active state |
96-| `gordon routes deploy <domain>` | Redeploy a route |121+| `gordon apps status APP` | Show effective vs observed state |
97-| `gordon pin <domain>` | Pin a route to a specific image tag |122+| `gordon apps logs APP` | Show logs for an app service |
98-| `gordon restart <domain>` | Restart a route container |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) |
99 128
100 ### Images & Registry129 ### Images & Registry
101 130
@@ -109,9 +138,9 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
109 138
110 | Command | Description |139 | Command | Description |
111 |---------|-------------|140 |---------|-------------|
112-| `gordon secrets list <domain>` | List secrets for a route |141+| `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |
113-| `gordon secrets set <domain> KEY=VAL` | Set secrets |142+| `gordon secrets list <domain>` | List installation secret keys |
114-| `gordon secrets remove <domain> <key>` | Remove a secret |143+| `gordon secrets set <domain> --from-file PATH` | Set installation secrets |
115 144
116 ### Remotes & Auth145 ### Remotes & Auth
117 146
@@ -125,19 +154,18 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
125 154
126 ## Features155 ## Features
127 156
128-- Private Docker registry on your VPS157+- Private Docker/Podman registry on your VPS
158+- Declarative apps: one TOML file per app, explicit apply then deploy
129 - Domain-to-container routing through a smart TCP edge reverse proxy159 - Domain-to-container routing through a smart TCP edge reverse proxy
130-- Automatic deployment on image push160+- HTTP zero-downtime updates (old container serves until replacement passes readiness)
131-- Auto-routing from image labels161+- Remote CLI management (daemon is the sole writer)
132-- Remote CLI management162+- Declarative per-service volumes, retained across lifecycle operations
133-- Zero downtime updates163+- Per-service secrets in pass, app-wide public env in the manifest
134-- Persistent volumes from Dockerfile VOLUME directives164+- Per-app private networks plus opt-in shared networks
135-- Environment variable management with secrets support165+- Single binary
136-- Network isolation per application
137-- Single binary, ~15MB RAM
138 166
139 > [!NOTE]167 > [!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.
141 169
142 ## Documentation170 ## Documentation
143 171
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`
135 135
136 Registry scopes: `push`, `pull`, `push,pull`136 Registry scopes: `push`, `pull`, `push,pull`
137 137
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`
139 139
140 Combine scopes with commas:140 Combine scopes with commas:
141 141
@@ -271,8 +271,8 @@ Usage:
271 | `pull` | Pull images from registry |271 | `pull` | Pull images from registry |
272 | `push,pull` | Both push and pull (default) |272 | `push,pull` | Both push and pull (default) |
273 | `admin:*:*` | Full admin access |273 | `admin:*:*` | Full admin access |
274-| `admin:routes:read` | Read-only routes access |274+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
275-| `admin:routes:write` | Routes write access |275+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
276 | `admin:config:read` | Read-only config access |276 | `admin:config:read` | Read-only config access |
277 | `admin:config:write` | Config write access |277 | `admin:config:write` | Config write access |
278 | `admin:status:read` | Read-only status/health |278 | `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 @@
1 # Backup Command1 # Backup Command
2 2
3-Manage database backups and volume backups.3+Manage app database backups and app volume backups.
4 4
5-## gordon backups databases5+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
6 36
7 Database backups are logical PostgreSQL backups made with `pg_dump`.37 Database backups are logical PostgreSQL backups made with `pg_dump`.
8 38
9 ```bash39 ```bash
10-gordon backups databases <subcommand>40+gordon backups <subcommand>
11 ```41 ```
12 42
13 Subcommands:43 Subcommands:
14 44
15-- `list [domain]` - List database backups45+- `list [app]` - List stored backups, for every app or one app
16-- `run <domain> [--db <name>]` - Trigger an immediate database backup46+- `run <app> --service <service> --database <database>` - Run one declared
17-- `detect <domain>` - Detect supported databases for a domain47+ database backup now
18-- `status` - Show database backup status48+- `status` - Show stored backups plus declared targets that have no completed
19-49+ backup yet
20-Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
21 50
22-## gordon backups volumes51+## gordon backups volume
23 52
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.
25 55
26 ```bash56 ```bash
27-gordon backups volumes <subcommand>57+gordon backups volume <subcommand>
28 ```58 ```
29 59
30 Subcommands:60 Subcommands:
31 61
32-- `list [domain]` - List completed volume backup archives62+- `list [app]` - List completed volume backup archives
33-- `run [domain] [--volume <name>]` - Trigger volume backups now63+- `run <app> --service <service> --volume <volume>` - Run one declared volume
64+ backup now
34 - `status` - Show completed archives plus current/recent in-memory job state65 - `status` - Show completed archives plus current/recent in-memory job state
35 66
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.
37 80
38 ## Examples81 ## Examples
39 82
40 ```bash83 ```bash
41 # Database backups84 # Database backups
42-gordon backups databases list85+gordon backups list
43-gordon backups databases run app.example.com --db postgres86+gordon backups list shop
44-gordon backups databases detect app.example.com87+gordon backups run shop --service api --database orders
45-gordon backups databases status88+gordon backups status
46 89
47 # Volume backups90 # Volume backups
48-gordon backups volumes list91+gordon backups volume list
49-gordon backups volumes list app.example.com92+gordon backups volume list shop
50-gordon backups volumes run93+gordon backups volume run shop --service api --volume data
51-gordon backups volumes run app.example.com --volume gordon-app-example-com-data94+gordon backups volume status
52-gordon backups volumes status
53 ```95 ```
54 96
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+
55 ## JSON Output104 ## JSON Output
56 105
57-Database and volume list commands support `--json`.106+Every list, run, and status command supports `--json`.
58 107
59 ```bash108 ```bash
60-gordon backups volumes list --json109+gordon backups volume list --json
61 ```110 ```
62 111
63 ## Required Permissions112 ## Required Permissions
64 113
65-- Read operations (`list`, `status`, `detect`) require `admin:status:read`.114+- Read operations (`list`, `status`) require `admin:status:read`.
66 - `run` requires `admin:config:write`.115 - `run` requires `admin:config:write`.
67 116
68 ## Related117 ## Related
69 118
70 - [CLI Commands](./index.md)119 - [CLI Commands](./index.md)
71 - [Backups Configuration](../config/backups.md)120 - [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).
17 17
18 ## gordon config show18 ## gordon config show
19 19
20-Display the Gordon server configuration including server settings,20+Display the Gordon installation 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.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.
22 23
23 ```bash24 ```bash
24 gordon config show25 gordon config show
@@ -41,17 +42,15 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
41 "registry_port": 5000,42 "registry_port": 5000,
42 "registry_domain": "reg.example.com"43 "registry_domain": "reg.example.com"
43 },44 },
44- "auto_route": {
45- "enabled": true,
46- "allowed_domains": ["example.com", "*.staging.example.com"]
47- },
48 "network_isolation": {45 "network_isolation": {
49 "enabled": true,46 "enabled": true,
50- "prefix": "gordon_"47+ "prefix": "gordon"
48+ },
49+ "volumes": {
50+ "auto_create": true,
51+ "prefix": "gordon",
52+ "preserve": true
51 },53 },
52- "routes": [
53- {"domain": "app.example.com", "image": "myapp:latest"}
54- ],
55 "external_routes": [54 "external_routes": [
56 {"domain": "reg.example.com"}55 {"domain": "reg.example.com"}
57 ]56 ]
@@ -60,13 +59,8 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
60 59
61 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.60 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
62 61
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-
67 ## Related62 ## Related
68 63
69 - [CLI Overview](./index.md)64 - [CLI Overview](./index.md)
70-- [Auto-Route Commands](./autoroute.md)
71 - [Status Command](./status.md)65 - [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]
59 59
60 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.60 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.
61 61
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+
62 Flags:64 Flags:
63 65
64 | Flag | Default | Description |66 | Flag | Default | Description |
65 |------|---------|-------------|67 |------|---------|-------------|
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 |
67 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |69 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
68 | `--dangling` | `false` | Restrict scope to dangling runtime images only |70 | `--dangling` | `false` | Restrict scope to dangling runtime images only |
69 | `--registry` | `false` | Restrict scope to registry tag retention only |71 | `--registry` | `false` | Restrict scope to registry tag retention only |
@@ -80,7 +82,22 @@ Flags:
80 82
81 - `latest` is always preserved when present.83 - `latest` is always preserved when present.
82 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.84 - `--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.
84 101
85 ## gordon images tags102 ## gordon images tags
86 103
@@ -128,7 +145,8 @@ gordon images prune --dangling
128 # Prune registry tags only, keeping latest + 5 previous145 # Prune registry tags only, keeping latest + 5 previous
129 gordon images prune --registry --keep-releases 5146 gordon images prune --registry --keep-releases 5
130 147
131-# Preview cleanup without applying148+# Inspect what prune would remove without applying
149+# (also lists every skipped candidate with its reason)
132 gordon images prune --dry-run150 gordon images prune --dry-run
133 151
134 # Skip confirmation prompt152 # Skip confirmation prompt
M docs/cli/index.md +43 -109
@@ -1,8 +1,8 @@
1 # CLI Commands1 # CLI Commands
2 2
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.
4 4
5-Most `list` commands also support `--json` for machine-readable output.5+Most `list`/`show` commands also support `--json` for machine-readable output.
6 6
7 Commands are organized by where they run:7 Commands are organized by where they run:
8 8
@@ -20,30 +20,23 @@ Commands are organized by where they run:
20 20
21 ## Management Commands (local or remote)21 ## Management Commands (local or remote)
22 22
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.
24 24
25 | Command | Description | Documentation |25 | Command | Description | Documentation |
26 |---------|-------------|---------------|26 |---------|-------------|---------------|
27-| `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |27+| `gordon apps` | Manage applications (apply, deploy, lifecycle) | [apps](./apps.md) |
28-| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |28+| `gordon backups` | Manage declared app database and volume backups | [backup](./backup.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) |
31 | `gordon config show` | Show server configuration | [config](./config.md) |29 | `gordon config show` | Show server configuration | [config](./config.md) |
32-| `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
33 | `gordon images` | List and prune images | [images](./images.md) |30 | `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) |
35 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |32 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
36-| `gordon preview` | Create or manage preview environments | [preview](../config/preview.md) |33+| `gordon push` | Tag and push an image (never deploys) | [push](./push.md) |
37-| `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |34+| `gordon reload` | Reload installation configuration | [serve](./serve.md#gordon-reload) |
38-| `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |35+| `gordon secrets` | Manage installation secrets | [secrets](./secrets.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) |
44 | `gordon status` | Show Gordon server status | [status](./status.md) |36 | `gordon status` | Show Gordon server status | [status](./status.md) |
37+| `gordon tls` | Inspect TLS status | [tls](./tls.md) |
45 | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |38 | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
46-| `gordon volumes` | Manage volumes | - |39+| `gordon volumes` | Manage volumes | [volumes](./volumes.md) |
47 40
48 ## Client Commands41 ## Client Commands
49 42
@@ -60,36 +53,30 @@ Management commands run locally through in-process services by default. Add `--r
60 gordon serve53 gordon serve
61 gordon serve --config /path/to/config.toml54 gordon serve --config /path/to/config.toml
62 55
63-# Reload configuration56+# Reload installation configuration (never activates app state)
64 gordon reload57 gordon reload
65 58
66-# Deploy a specific route59+# Applications (daemon-owned; fail without a reachable daemon)
67-gordon deploy myapp.example.com60+gordon apps apply --file blog.toml
68-61+gordon apps apply --file blog.toml --deploy
69-# Restart a running container62+gordon apps list
70-gordon restart myapp.example.com63+gordon apps show blog
71-64+gordon apps diff blog
72-# First-time route setup65+gordon apps deploy blog
73-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production66+gordon apps restart blog
74-67+gordon apps stop blog
75-# Then push and deploy68+gordon apps start blog
76-gordon push myapp:latest --domain app.example.com --build --no-confirm69+gordon apps remove blog
77-70+
78-# Push an image and deploy71+# Push an image (OCI transfer only; deploy separately)
79-gordon push myapp --build72+gordon push myapp --build --remote prod
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
86 73
87 # View logs74 # View logs
88-gordon logs # Gordon process logs75+gordon logs # Gordon process logs
89-gordon logs -f # Follow process logs76+gordon logs -f # Follow process logs
90-gordon logs -n 100 # Last 100 lines77+gordon logs -n 100 # Last 100 process-log lines
91-gordon logs myapp.example.com # Container logs for myapp.example.com78+gordon apps logs blog --service web # App service logs
92-gordon logs myapp.example.com -f # Follow container logs79+gordon apps logs blog --service web --follow # Follow app service logs
93 80
94 # Check version81 # Check version
95 gordon version82 gordon version
@@ -100,14 +87,14 @@ gordon traffic status --remote prod --json
100 87
101 # Backups88 # Backups
102 gordon backups list89 gordon backups list
103-gordon backups run app.example.com90+gordon backups run shop --service api --database orders
104-gordon backups detect app.example.com91+gordon backups volume run shop --service api --volume data
105 gordon backups status92 gordon backups status
106 93
107 # Images94 # Images
108 gordon images list95 gordon images list
109-gordon images prune --runtime-only96+gordon images prune --dry-run
110-gordon images prune --keep 397+gordon images prune --keep-releases 3
111 98
112 # Authentication99 # Authentication
113 gordon auth login --remote https://gordon.example.com --token $TOKEN100 gordon auth login --remote https://gordon.example.com --token $TOKEN
@@ -119,21 +106,9 @@ gordon auth token list
119 gordon auth token revoke <token-id>106 gordon auth token revoke <token-id>
120 gordon auth internal107 gordon auth internal
121 108
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-
134 # Secrets109 # Secrets
135 gordon secrets list myapp.example.com110 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
137 gordon secrets remove myapp.example.com DATABASE_URL112 gordon secrets remove myapp.example.com DATABASE_URL
138 113
139 # Remotes114 # Remotes
@@ -141,21 +116,9 @@ gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
141 gordon remotes list116 gordon remotes list
142 gordon remotes use prod117 gordon remotes use prod
143 118
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-
150 # Volumes119 # Volumes
151 gordon volumes list120 gordon volumes list
152 gordon volumes prune121 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
159 ```122 ```
160 123
161 ## Global Options124 ## Global Options
@@ -173,55 +136,26 @@ The CLI can target remote Gordon instances using client config, an active remote
173 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides136 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
174 when you want to bypass your saved configuration.137 when you want to bypass your saved configuration.
175 138
176-When no explicit remote is selected and no active remote is configured, Gordon can now139+When no explicit remote is selected and no active remote is configured, Gordon can
177-**auto-infer a saved remote** for commands that already have a concrete target. It probes your140+**auto-infer a saved remote** for `gordon push`. It probes your saved remotes and uses the
178-saved remotes and uses the remote automatically when exactly one matches. If multiple remotes141+remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
179-match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe142+an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
180-fails, Gordon also stops rather than guessing.143+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.
198 144
199 ```bash145 ```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-
208 # Auto-inferred single match from saved remotes146 # Auto-inferred single match from saved remotes
209 gordon push myapp --build147 gordon push myapp --build
210-gordon deploy app.example.com
211 ```148 ```
212 149
213 **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.150 **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.
215 151
216 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.152 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
217 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`153 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`
218 or in a specific entry in `~/.config/gordon/remotes.toml`.154 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.
221 155
222 ```bash156 ```bash
223 # Using flags (use the gordon_domain from remote Gordon config)157 # Using flags (use the gordon_domain from remote Gordon config)
224-gordon routes list --remote https://gordon.example.com --token $TOKEN158+gordon status --remote https://gordon.example.com --token $TOKEN
225 159
226 # Against self-signed/private CA endpoint160 # Against self-signed/private CA endpoint
227 gordon --remote https://gordon.example.com --token $TOKEN --insecure status161 gordon --remote https://gordon.example.com --token $TOKEN --insecure status
@@ -230,7 +164,7 @@ gordon --remote https://gordon.example.com --token $TOKEN --insecure status
230 export GORDON_REMOTE=https://gordon.example.com164 export GORDON_REMOTE=https://gordon.example.com
231 export GORDON_TOKEN=$TOKEN165 export GORDON_TOKEN=$TOKEN
232 export GORDON_INSECURE=true166 export GORDON_INSECURE=true
233-gordon routes list167+gordon status
234 ```168 ```
235 169
236 ## Exit Codes170 ## Exit Codes
M docs/cli/networks.md +0 -2
@@ -47,5 +47,3 @@ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
47 ## Related47 ## Related
48 48
49 - [CLI Overview](./index.md)49 - [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 @@
1 # Push Command1 # Push Command
2 2
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.
4 6
5 ## gordon push7 ## gordon push
6 8
@@ -25,35 +27,22 @@ gordon push [image] [options]
25 | `--platform` | Target platform for buildx (default: `linux/amd64`) |27 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
26 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |28 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27 | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |29 | `--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 |
31 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |30 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
32 | `--token` | Authentication token for remote |31 | `--token` | Authentication token for remote |
33 32
34 ### Description33 ### Description
35 34
36-`gordon push` tags the selected image for the Gordon registry, pushes it, and35+`gordon push` tags the selected image for the Gordon registry and pushes it.
37-optionally deploys matching routes.
38 36
39 If you do not pass `--remote` and no active remote is configured, Gordon tries to37 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.
41 It auto-selects only when exactly one remote matches. If multiple remotes match,39 It auto-selects only when exactly one remote matches. If multiple remotes match,
42 or a saved remote cannot be probed safely, the command stops and asks you to use40 or a saved remote cannot be probed safely, the command stops and asks you to use
43 `--remote` explicitly.41 `--remote` explicitly.
44 42
45-Image resolution order:43+Domain-style push targets are retired: push an image name, then
46-1. `--domain` is the explicit deploy target override for legacy workflows44+`gordon apps apply` the manifest that references the pushed tag.
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
50 45
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.
57 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,46 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
58 then falls back to `git describe --tags --dirty` (for example47 then falls back to `git describe --tags --dirty` (for example
59 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.48 `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
69 58
70 When used with `--remote`, gordon push authenticates in two ways:59 When used with `--remote`, gordon push authenticates in two ways:
71 60
72-- **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token61+- **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token
73 - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required62 - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
74 63
75 This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).64 This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
76 65
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-
86 ### Version Auto-Detection66 ### Version Auto-Detection
87 67
88 Gordon reads version tags from CI environment variables (in priority order):68 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):
99 ### Examples79 ### Examples
100 80
101 ```bash81 ```bash
102-# Build, push, and deploy (auto-detect image name)82+# Build, push (auto-detect image name)
103-gordon push --build --remote https://gordon.example.com --no-confirm83+gordon push --build --remote https://gordon.example.com
104-
105-# Push an image and deploy it
106-gordon push myapp --remote https://gordon.example.com --no-confirm
107 84
108-# Push and deploy to an explicit domain85+# Push an image
109-gordon push myapp:latest --domain app.example.com --remote https://gordon.example.com --no-confirm86+gordon push myapp --remote https://gordon.example.com
110 87
111-# Tagged refs still resolve routes by image name88+# Push a fully qualified ref
112-gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy89+gordon push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com
113 90
114-# Push existing local image, skip deploy91+# Push existing local image with an explicit tag
115-gordon push myapp --tag v1.2.0 --no-deploy92+gordon push myapp --tag v1.2.0 --remote https://gordon.example.com
116 93
117 # Build for ARM and pass build args94 # Build for ARM and pass build args
118-gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=095+gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com
119 96
120 # Build from a custom Dockerfile path97 # Build from a custom Dockerfile path
121-gordon push myapp --build -f docker/app/Dockerfile98+gordon push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com
122-
123-# Legacy compatibility: domain-looking positional target
124-gordon push app.example.com --no-confirm
125 99
126 # CI/CD usage (single env var, no docker login needed)100 # CI/CD usage (single env var, no docker login needed)
127 export GORDON_TOKEN="your-token"101 export GORDON_TOKEN="your-token"
128-gordon push myapp --build --remote https://gordon.example.com --no-confirm102+gordon push myapp --build --remote https://gordon.example.com
129 ```103 ```
130 104
131 ### Notes105 ### Notes
132 106
133 - Remote mode required. See [CLI Overview](./index.md) for targeting options.107 - 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.
136 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,108 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
137 install the `docker-buildx-plugin` package.109 install the `docker-buildx-plugin` package.
138 - Gordon uses native registry uploads instead of shelling out to `docker push`.110 - 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
144 ## Related116 ## Related
145 117
146 - [CLI Overview](./index.md)118 - [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)
151 - [Authentication](../config/auth.md)119 - [Authentication](../config/auth.md)
M docs/cli/remotes.md +11 -22
@@ -163,15 +163,13 @@ gordon remotes use <name>
163 ### Description163 ### Description
164 164
165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.165 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`.
167 166
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`.
169 168
170 ```bash169 ```bash
171 gordon remotes use prod170 gordon remotes use prod
172 gordon secrets list app.com # Uses prod remote automatically171 gordon secrets list app.com # Uses prod remote automatically
173-gordon routes list # Aggregates local + saved remotes172+gordon status # Uses prod remote automatically
174-gordon routes status # Aggregates local + saved remotes
175 ```173 ```
176 174
177 ### Example175 ### Example
@@ -246,7 +244,7 @@ token = "eyJ..."
246 244
247 When multiple sources specify remote or token, the CLI uses this priority:245 When multiple sources specify remote or token, the CLI uses this priority:
248 246
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.
250 Auto-inference is not used for those aggregate views.248 Auto-inference is not used for those aggregate views.
251 249
252 **Remote target selection:**250 **Remote target selection:**
@@ -271,13 +269,8 @@ Auto-inference is not used for those aggregate views.
271 This allows overriding specific values while keeping defaults:269 This allows overriding specific values while keeping defaults:
272 270
273 ```bash271 ```bash
274-# Aggregate routes views
275-gordon routes list
276-gordon routes status
277-
278 # Force one target272 # Force one target
279-GORDON_REMOTE=prod gordon routes list273+GORDON_REMOTE=prod gordon status
280-GORDON_REMOTE=prod gordon routes status
281 ```274 ```
282 275
283 ---276 ---
@@ -295,11 +288,11 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
295 # Work with prod288 # Work with prod
296 gordon remotes use prod289 gordon remotes use prod
297 gordon secrets list myapp.example.com290 gordon secrets list myapp.example.com
298-gordon routes list --remote prod291+gordon status --remote prod
299 292
300 # Switch to staging293 # Switch to staging
301 gordon remotes use staging294 gordon remotes use staging
302-GORDON_REMOTE=staging gordon routes status295+GORDON_REMOTE=staging gordon status
303 ```296 ```
304 297
305 ### CI/CD Pipeline298 ### CI/CD Pipeline
@@ -311,21 +304,17 @@ env:
311 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}304 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
312 305
313 steps:306 steps:
314- - name: Deploy to Gordon307+ - name: Push to Gordon
315 run: |308 run: |
316- gordon routes deploy myapp.example.com309+ gordon push myapp --build --remote ${{ secrets.GORDON_URL }}
317 ```310 ```
318 311
319 ### Compare Environments312 ### Compare Environments
320 313
321 ```bash314 ```bash
322-# Aggregate route inventory/status across local + saved remotes
323-gordon routes list
324-gordon routes status
325-
326 # Compare one target at a time315 # Compare one target at a time
327-gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN316+gordon status --remote https://gordon.example.com --token $PROD_TOKEN
328-GORDON_REMOTE=staging gordon routes status317+GORDON_REMOTE=staging gordon status
329 318
330 # Or switch between active remotes for other commands319 # Or switch between active remotes for other commands
331 gordon remotes use prod && gordon secrets list myapp.example.com320 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
354 343
355 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.344 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.
356 345
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`.
358 347
359 ## Migration from [client] Config348 ## Migration from [client] Config
360 349
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 @@
1 # Secrets Commands1 # Secrets Commands
2 2
3-Manage secrets on local or remote Gordon instances.3+Manage installation 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).
9 4
10 Storage depends on the secrets backend:5 Storage depends on the secrets backend:
11 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`6 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
12 - `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values7 - `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
13 - `unsafe`: secrets are stored in plain-text domain `.env` files8 - `unsafe`: secrets are stored in plain-text domain `.env` files
14 9
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+
15 ## gordon secrets14 ## gordon secrets
16 15
17 ### Subcommands16 ### Subcommands
18 17
19 | Subcommand | Description |18 | Subcommand | Description |
20 |------------|-------------|19 |------------|-------------|
21-| `list` | List all secrets for a domain |20+| `list` | List all secret keys for a domain |
22-| `set` | Set a secret value |21+| `set` | Set secrets for a domain from a file |
23 | `remove` | Remove a secret |22 | `remove` | Remove a secret |
24 23
25 ---24 ---
26 25
27 ## gordon secrets list26 ## gordon secrets list
28 27
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.
30 29
31 ```bash30 ```bash
32 gordon secrets list <domain>31 gordon secrets list <domain>
@@ -62,13 +61,8 @@ gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --tok
62 Secrets for app.mydomain.com61 Secrets for app.mydomain.com
63 62
64 Key Value63 Key Value
65-DATABASE_URL ****64+DATABASE_URL (hidden)
66-API_KEY ****65+API_KEY (hidden)
67-├─ [postgres]
68-│ ├─ POSTGRES_USER ****
69-│ └─ POSTGRES_PASSWORD ****
70-└─ [redis]
71- └─ REDIS_PASSWORD ****
72 ```66 ```
73 67
74 ### JSON Output68 ### JSON Output
@@ -80,19 +74,7 @@ gordon secrets list app.mydomain.com --json
80 ```json74 ```json
81 {75 {
82 "domain": "app.mydomain.com",76 "domain": "app.mydomain.com",
83- "secrets": {77+ "keys": ["API_KEY", "DATABASE_URL"]
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- }
96 }78 }
97 ```79 ```
98 80
@@ -100,25 +82,24 @@ gordon secrets list app.mydomain.com --json
100 82
101 ## gordon secrets set83 ## gordon secrets set
102 84
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.
104 86
105 ```bash87 ```bash
106-gordon secrets set <domain> <KEY=value>...88+gordon secrets set <domain> --from-file <path>
107-gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
108 ```89 ```
109 90
110 ### Arguments91 ### Arguments
111 92
112 | Argument | Description |93 | Argument | Description |
113 |----------|-------------|94 |----------|-------------|
114-| `<domain>` | The domain name to set the secret for |95+| `<domain>` | The domain name to set secrets for |
115-| `<KEY=value>...` | One or more secret key/value pairs |
116 96
117 ### Options97 ### Options
118 98
119 | Option | Description |99 | Option | Description |
120 |--------|-------------|100 |--------|-------------|
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 |
122 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |103 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
123 | `--token` | Authentication token for remote |104 | `--token` | Authentication token for remote |
124 105
@@ -126,18 +107,10 @@ gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
126 107
127 ```bash108 ```bash
128 # Local109 # Local
129-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db"110+gordon secrets set myapp.example.com --from-file ./app.env
130-gordon secrets set myapp.example.com API_KEY="your-api-key"
131 111
132 # Remote (override)112 # Remote (override)
133-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN113+gordon secrets set myapp.example.com --from-file ./app.env --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
141 ```114 ```
142 115
143 ---116 ---
@@ -161,8 +134,7 @@ gordon secrets remove <domain> <key>
161 134
162 | Option | Description |135 | Option | Description |
163 |--------|-------------|136 |--------|-------------|
164-| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |137+| `--force`, `-f` | Remove without confirmation |
165-| `--force` | Remove without confirmation |
166 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |138 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
167 | `--token` | Authentication token for remote |139 | `--token` | Authentication token for remote |
168 140
@@ -174,9 +146,6 @@ gordon secrets remove myapp.example.com DATABASE_URL
174 146
175 # Remote (override)147 # Remote (override)
176 gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN148 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
180 ```149 ```
181 150
182 ---151 ---
@@ -186,15 +155,14 @@ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
186 ### Setting Up Application Secrets155 ### Setting Up Application Secrets
187 156
188 ```bash157 ```bash
189-# Database connection158+# Write secrets to a protected file
190-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/mydb"159+cat > app.env <<'EOF'
191-160+DATABASE_URL=postgres://user:pass@postgres:5432/mydb
192-# API keys161+API_KEY=your-api-key
193-gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..."162+EOF
194-gordon secrets set myapp.example.com SENDGRID_KEY="SG..."163+chmod 600 app.env
195 164
196-# JWT secret165+gordon secrets set myapp.example.com --from-file ./app.env
197-gordon secrets set myapp.example.com JWT_SECRET="your-jwt-secret-here"
198 166
199 # Verify167 # Verify
200 gordon secrets list myapp.example.com168 gordon secrets list myapp.example.com
@@ -207,12 +175,8 @@ gordon secrets list myapp.example.com
207 export GORDON_REMOTE=https://gordon.mydomain.com175 export GORDON_REMOTE=https://gordon.mydomain.com
208 export GORDON_TOKEN=$GORDON_TOKEN176 export GORDON_TOKEN=$GORDON_TOKEN
209 177
210-# Update secrets before deploy178+# Update secrets
211-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL"179+gordon secrets set myapp.example.com --from-file ./app.env
212-gordon secrets set myapp.example.com API_KEY="$API_KEY"
213-
214-# Deploy
215-gordon deploy myapp.example.com
216 ```180 ```
217 181
218 ### Rotating Secrets182 ### Rotating Secrets
@@ -220,28 +184,11 @@ gordon deploy myapp.example.com
220 ```bash184 ```bash
221 # Generate new secret185 # Generate new secret
222 NEW_JWT_SECRET=$(openssl rand -base64 32)186 NEW_JWT_SECRET=$(openssl rand -base64 32)
187+printf 'JWT_SECRET=%s\n' "$NEW_JWT_SECRET" > rotate.env
188+chmod 600 rotate.env
223 189
224 # Update the secret190 # Update the secret
225-gordon secrets set myapp.example.com JWT_SECRET="$NEW_JWT_SECRET"191+gordon secrets set myapp.example.com --from-file ./rotate.env
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
245 ```192 ```
246 193
247 ## Related194 ## Related
@@ -249,3 +196,4 @@ gordon deploy app.mydomain.com
249 - [CLI Overview](./index.md)196 - [CLI Overview](./index.md)
250 - [Secrets Configuration](../config/secrets.md)197 - [Secrets Configuration](../config/secrets.md)
251 - [Environment Variables](../config/env.md)198 - [Environment Variables](../config/env.md)
199+- [Apps Commands](./apps.md)
M docs/cli/serve.md +26 -97
@@ -23,8 +23,8 @@ gordon serve [options]
23 Starts the Gordon server, which includes:23 Starts the Gordon server, which includes:
24 24
25 - **Container Registry** - Receives image pushes on the registry port25 - **Container Registry** - Receives image pushes on the registry port
26-- **HTTP Proxy** - Routes traffic to containers on the proxy port26+- **Reverse Proxy** - Routes app hosts to recorded loopback backends
27-- **Event Bus** - Coordinates deployments and updates27+- **Event Bus** - Coordinates runtime notifications
28 - **Config Watcher** - Monitors configuration file for changes28 - **Config Watcher** - Monitors configuration file for changes
29 29
30 ### Configuration File Detection30 ### Configuration File Detection
@@ -68,8 +68,8 @@ Gordon responds to these signals:
68 |--------|--------|68 |--------|--------|
69 | `SIGTERM` | Graceful shutdown |69 | `SIGTERM` | Graceful shutdown |
70 | `SIGINT` | Graceful shutdown (Ctrl+C) |70 | `SIGINT` | Graceful shutdown (Ctrl+C) |
71-| `SIGUSR1` | Reload configuration |71+| `SIGUSR1` | Reload installation configuration |
72-| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |72+| `SIGUSR2` | Reserved; no app deployment action |
73 73
74 ### Running with systemd74 ### Running with systemd
75 75
@@ -106,21 +106,16 @@ sudo loginctl enable-linger $USER
106 7. Register event handlers106 7. Register event handlers
107 8. Start config file watcher107 8. Start config file watcher
108 9. Start HTTP servers108 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)
110 110
111 ### Startup Recovery111 ### Startup Recovery
112 112
113 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.113 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.
114 114
115-1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.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-2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.116+4. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
117-3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
118 117
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.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.
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.
124 119
125 ### Shutdown Sequence120 ### Shutdown Sequence
126 121
@@ -135,7 +130,7 @@ Startup recovery is intentionally narrower than a manual deploy. It only starts
135 130
136 ## gordon reload131 ## gordon reload
137 132
138-Reload configuration and sync containers to match.133+Reload installation configuration.
139 134
140 ### Synopsis135 ### Synopsis
141 136
@@ -147,10 +142,10 @@ gordon reload
147 142
148 Sends `SIGUSR1` to the running Gordon process, triggering:143 Sends `SIGUSR1` to the running Gordon process, triggering:
149 144
150-- Configuration file reload145+- Installation settings reload (live keys apply, restart-required keys are reported)
151-- Route synchronization146+- Traffic/proxy state refresh from ACTIVE app projection
152-- Deployment of containers for routes missing containers147+
153-- Attachment deployment148+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.
154 149
155 ### Example150 ### Example
156 151
@@ -162,80 +157,18 @@ gordon reload
162 157
163 ---158 ---
164 159
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-
223 ## gordon logs160 ## gordon logs
224 161
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.
226 165
227 ### Synopsis166 ### Synopsis
228 167
229 ```bash168 ```bash
230-gordon logs [domain] [options]169+gordon logs [options]
231 ```170 ```
232 171
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
238-
239 ### Options172 ### Options
240 173
241 | Option | Short | Default | Description |174 | Option | Short | Default | Description |
@@ -246,10 +179,8 @@ gordon logs [domain] [options]
246 | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |179 | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
247 | `--token` | | | Authentication token for remote |180 | `--token` | | | Authentication token for remote |
248 181
249-Remote targeting uses client config or an active remote by default.182+Remote targeting uses client config or an active remote by default. Use
250-When you provide a concrete domain and no remote is selected, Gordon can also183+`--remote` and `--token` to override. See [CLI Overview](./index.md).
251-auto-infer a saved remote when exactly one match is found.
252-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
253 184
254 Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.185 Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
255 186
@@ -262,14 +193,12 @@ gordon logs -f # Follow logs
262 gordon logs -n 100 # Last 100 lines193 gordon logs -n 100 # Last 100 lines
263 gordon logs -f -n 200 # Follow, starting from last 200 lines194 gordon logs -f -n 200 # Follow, starting from last 200 lines
264 195
265-# Container logs196+# App service logs
266-gordon logs myapp.example.com # Last 50 lines from container197+gordon apps logs blog --service web
267-gordon logs myapp.example.com -f # Follow container logs198+gordon apps logs blog --service web --follow --remote prod
268-gordon logs myapp.example.com -n 100 # Last 100 lines from container
269 199
270-# Remote mode (override)200+# Remote process logs (override)
271 gordon logs --remote https://gordon.mydomain.com --token $TOKEN201 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
272-gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
273 ```202 ```
274 203
275 ### Log Locations204 ### Log Locations
@@ -284,9 +213,9 @@ tail -f ~/.gordon/logs/gordon.log
284 # With systemd213 # With systemd
285 journalctl --user -u gordon -f214 journalctl --user -u gordon -f
286 215
287-# Container logs via docker (local alternative)216+# App service logs through Gordon
288-docker logs --tail 50 myapp.example.com217+gordon apps logs blog --service web --tail 50
289-docker logs -f myapp.example.com218+gordon apps logs blog --service web --follow
290 ```219 ```
291 220
292 ---221 ---
M docs/cli/status.md +20 -23
@@ -1,10 +1,10 @@
1 # Status Command1 # Status Command
2 2
3-Show Gordon server status and container health.3+Show Gordon server status and the app fleet summary.
4 4
5 ## gordon status5 ## gordon status
6 6
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`.
8 8
9 ```bash9 ```bash
10 gordon status10 gordon status
@@ -24,14 +24,13 @@ Gordon Status
24 Gordon Domain: gordon.example.com24 Gordon Domain: gordon.example.com
25 Registry Port: 500025 Registry Port: 5000
26 Server Port: 808826 Server Port: 8088
27-Routes: 327+Apps: 3
28-Auto-Route: true
29 Network Isolation: false28 Network Isolation: false
30 29
31 Container Status:30 Container Status:
32- app.example.com: running31+ blog: active
33- api.example.com: running32+ shop: deploying
34- worker.example.com: stopped33+ old-site: stopped
35 ```34 ```
36 35
37 ### Information Displayed36 ### Information Displayed
@@ -40,22 +39,19 @@ Container Status:
40 |-------|-------------|39 |-------|-------------|
41 | Gordon Domain | Public Gordon domain from configuration |40 | Gordon Domain | Public Gordon domain from configuration |
42 | Registry Port | Docker registry port |41 | Registry Port | Docker registry port |
43-| Server Port | Gordon HTTP proxy port |42+| Server Port | Gordon admin port |
44-| Routes | Total configured routes |43+| Routes | Total apps in desired/active state |
45-| Auto-Route | Whether auto-routing is enabled |44+| Network Isolation | Whether installation network policy is enabled |
46-| Network Isolation | Whether network isolation is enabled |45+| Container Status | Fleet status per app (see states below) |
47-| Container Status | Status of each route's container |
48 46
49-### Container States47+### App States
50 48
51 | State | Description |49 | State | Description |
52 |-------|-------------|50 |-------|-------------|
53-| running | Container is running and healthy |51+| active | App deployed and converged on desired state |
54-| restarting | Container is in runtime restart backoff or restart cycle |52+| deploying | Desired state diverges from effective state |
55-| stopped | Container was stopped |53+| pending | App applied but never deployed |
56-| exited | Container exited (check logs for errors) |54+| stopped | Durable stopped intent (stays stopped across reboot) |
57-| paused | Container is paused |
58-| unknown | Unable to determine container state |
59 55
60 ## Flags56 ## Flags
61 57
@@ -90,11 +86,11 @@ export GORDON_TOKEN=your-token
90 gordon status86 gordon status
91 ```87 ```
92 88
93-### Quick Health Check89+### Quick Fleet Check
94 90
95 ```bash91 ```bash
96-# Check if all containers are running92+# Check for non-converged apps
97-gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"93+gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)"
98 ```94 ```
99 95
100 ## Required Permissions (Remote Only)96 ## Required Permissions (Remote Only)
@@ -109,5 +105,6 @@ gordon auth token generate --subject admin --scopes admin:status:read
109 ## Related105 ## Related
110 106
111 - [Serve Command](./serve.md)107 - [Serve Command](./serve.md)
112-- [Routes Command](./routes.md)108+- [Apps Commands](./apps.md)
109+- [CLI Overview](./index.md)
113 - [Remote CLI Management](/wiki/guides/remote-cli.md)110 - [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
111 111
112 - [CLI Overview](./index.md)112 - [CLI Overview](./index.md)
113 - [Status Command](./status.md)113 - [Status Command](./status.md)
114-- [Routes Command](./routes.md)
M docs/cli/volumes.md +14 -2
@@ -19,7 +19,7 @@ gordon volumes list --json
19 19
20 ### `gordon volumes prune`20 ### `gordon volumes prune`
21 21
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.
23 23
24 ```bash24 ```bash
25 gordon volumes prune25 gordon volumes prune
@@ -29,10 +29,22 @@ gordon volumes prune --no-confirm
29 29
30 | Flag | Description |30 | Flag | Description |
31 |------|-------------|31 |------|-------------|
32-| `--dry-run` | Show what would be removed without deleting |32+| `--dry-run` | Report the plan without deleting |
33 | `--no-confirm` | Skip confirmation prompt |33 | `--no-confirm` | Skip confirmation prompt |
34 | `--json` | Output as JSON |34 | `--json` | Output as JSON |
35 35
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+
36 ## Related48 ## Related
37 49
38 - [Volumes Configuration](../config/volumes.md)50 - [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
9 Gordon flips the typical deployment model:9 Gordon flips the typical deployment model:
10 10
11 1. **Build locally** where you have computing power11 1. **Build locally** where you have computing power
12-2. **Push the finished image** to your VPS12+2. **Push the finished image** to your VPS registry
13-3. **Gordon deploys automatically**13+3. **Apply the app manifest** declaring the image
14+4. **Deploy** to activate it
14 15
15 This means faster builds, less VPS resource usage, and a simpler deployment workflow.16 This means faster builds, less VPS resource usage, and a simpler deployment workflow.
16 17
17-## Push-to-Deploy18+## Push, Apply, Deploy
18 19
19-Gordon combines a Docker registry with automatic deployment:20+Gordon combines a Docker registry with a declarative app runtime:
20 21
21 ```22 ```
22 ┌──────────────┐ push ┌──────────────┐23 ┌──────────────┐ push ┌──────────────┐
23 │ docker build │ ──────────────>│ Gordon │24 │ docker build │ ──────────────>│ Gordon │
24-│ docker push │ │ Registry │25+│ docker push │ (OCI only, │ Registry │
25-└──────────────┘ └──────┬───────┘26+└──────────────┘ never deploys)└──────┬───────┘
26 │27 │
27- │ event: image.pushed28+ ┌───────────────────┴───────────────────┐
29+ │ gordon apps apply --file blog.toml │ desired state
30+ │ gordon apps deploy blog │ activation
31+ └───────────────────┬───────────────────┘
28 v32 v
29 ┌──────────────┐33 ┌──────────────┐
30- │ Deploy │34+ │ App │
31- │ Container │35+ │ Containers │
32 └──────────────┘36 └──────────────┘
33 ```37 ```
34 38
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`.
36 40
37-1. Stores the image in its registry41+## Declarative Apps
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
43 42
44-## Zero-Downtime Updates43+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.
45 44
46-Gordon ensures your app stays available during updates:45+```toml
47-46+name = "blog"
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:
78 47
79-- Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`.48+[env]
80-- Gordon can stop and remove containers.49+APP_ENV = "production" # app-wide public env, injected into all services
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.
83 50
84-## Routes51+[[service]]
52+name = "web"
53+image = "gordon.mydomain.com/blog:1.4.2"
85 54
86-Routes map domains to container images:55+[[service.http]]
56+host = "blog.mydomain.com"
57+port = 3000
87 58
88-```toml59+[service.secrets] # ENV name -> secret name (values stay in pass)
89-[routes]60+DATABASE_URL = "database-url"
90-"app.example.com" = { image = "myapp:latest" }
91-"api.example.com" = { image = "myapi:v2.1.0" }
92 ```61 ```
93 62
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.
95 69
96-When a request comes in for `app.example.com`, Gordon:70+## Updates
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
101 71
102-### Route Domains72+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.
103 73
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 ─────────────────────────────────────────────>
105 76
106-```toml77+Old Container: [═══════════════════]
107-[routes]78+ ↓ retire
108-"dev-app.example.com" = { image = "internal-app:latest", https = false }79+New Container: [═════════════════════════>
80+ ↑ start ↑ traffic routed
109 ```81 ```
110 82
111-## Network Isolation83+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.
112 84
113-Each app runs in its own isolated Docker network:85+## Deletion and Cleanup Lifecycle
114 86
115-```87+Gordon separates workload removal from destructive data cleanup:
116-┌────────────────────────────────────────────────┐
117-│ gordon-app-mydomain-com │
118-│ │
119-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
120-│ │ App │───>│ Postgres │ │ Redis │ │
121-│ │ :3000 │ │ :5432 │ │ :6379 │ │
122-│ └──────────┘ └──────────┘ └──────────┘ │
123-│ │
124-└────────────────────────────────────────────────┘
125-```
126 88
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.
128 93
129-- Containers can't access each other's services94+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.
130-- Services are only accessible by name within their network
131-- No port conflicts between apps
132 95
133-## Attachments96+## App HTTP Hosts
134 97
135-Attachments are service dependencies for your apps:98+An app's HTTP interfaces declare the hosts Gordon serves:
136 99
137 ```toml100 ```toml
138-[attachments]101+[[service.http]]
139-"app.mydomain.com" = ["postgres:latest", "redis:latest"]102+host = "app.example.com"
103+port = 3000
140 ```104 ```
141 105
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
143 111
144-```javascript112+Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`).
145-// In your app
146-const db = await connect("postgresql://postgres:5432/mydb");
147-const cache = await connect("redis://redis:6379");
148-```
149 113
150-## Network Groups114+## Networks
151 115
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:
153 117
154 ```toml118 ```toml
155-[network_groups]119+[[network.shared]]
156-"backend" = ["app.mydomain.com", "api.mydomain.com"]120+network = "backend"
157-121+services = ["web", "worker"]
158-[attachments]
159-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
160 ```122 ```
161 123
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.
163 125
164 ## Volumes126 ## Volumes
165 127
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:
167 129
168-```dockerfile130+```toml
169-FROM postgres:18131+[[service.volume]]
170-VOLUME ["/var/lib/postgresql/data"]132+name = "web-data"
133+path = "/data"
171 ```134 ```
172 135
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.
174 137
175-- **auto_create**: Volumes are created automatically (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.
176-- **prefix**: Volume names are prefixed with `gordon-` (configurable)
177-- **preserve**: Volumes persist across container updates (default: true)
178 139
179-## Environment Variables140+## Environment and Secrets
180 141
181-Gordon loads environment variables from files based on the domain:142+App-wide public env is declared in the manifest:
182 143
183-```144+```toml
184-~/.gordon/env/145+[env]
185-├── app_mydomain_com.env146+APP_ENV = "production"
186-├── api_mydomain_com.env
187-└── admin_mydomain_com.env
188 ```147 ```
189 148
190-Domain dots become underscores: `app.mydomain.com` → `app_mydomain_com.env`149+Service-specific values use `secrets` even when non-confidential:
191 150
192-Variables are merged in order:151+```toml
193-152+[service.secrets]
194-1. Dockerfile `ENV` directives (lowest priority)153+DATABASE_URL = "database-url"
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}
207 ```154 ```
208 155
209-## Configuration Hot-Reload156+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.
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
217 157
218-Route file edits now reload automatically again.158+## Installation Reload
219 159
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.
221 161
222 ```bash162 ```bash
223 gordon reload163 gordon reload
224 ```164 ```
225 165
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.
227 167
228 ## Event System168 ## Event System
229 169
230-Gordon uses an internal event system for coordination: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.
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 |
238 171
239 ## Backups and Recovery172 ## Backups and Recovery
240 173
241-Gordon can run logical PostgreSQL backups for attachment containers.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.
242-
243-Current design:
244 175
245-1. Detect PostgreSQL attachments attached to a route176+Stored backups are never deleted when declarations change — only schedules update on deploy.
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
249 177
250-This is intentionally scoped for operational safety and predictable behavior.
251-Future extensions can add physical backups, PITR, and remote object storage adapters.
252 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).178 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).
253 179
254-## Container Labels180+## Container Identity
255 181
256-Gordon uses labels to track managed containers:182+Gordon stamps app ownership labels on every container and volume it creates:
257 183
258 | Label | Purpose |184 | Label | Purpose |
259 |-------|---------|185 |-------|---------|
260-| `gordon.managed=true` | Identifies Gordon-managed containers |186+| `gordon.managed=true` | Identifies Gordon-managed resources |
261-| `gordon.domain` | Domain this container serves |187+| `gordon.app` | App public name |
262-| `gordon.image` | Image name and tag |188+| `gordon.app.service` | Service name |
263-| `gordon.route` | Route this container handles |189+| `gordon.app.revision` | Active revision that created it |
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-```
277 190
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.
279 192
280 ## Related193 ## Related
281 194
282 - [Configuration Reference](./config/index.md)195 - [Configuration Reference](./config/index.md)
283-- [Docker Labels Reference](./reference/docker-labels.md)196+- [Apps CLI](./cli/apps.md)
284-- [Environment Variables](./config/env.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):
87 | Scope | Permission |87 | Scope | Permission |
88 |-------|------------|88 |-------|------------|
89 | `admin:*:*` | Full admin access |89 | `admin:*:*` | Full admin access |
90-| `admin:routes:read` | Read-only routes access |90+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
91-| `admin:routes:write` | Routes write access |91+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
92 | `admin:config:read` | Read-only config access |92 | `admin:config:read` | Read-only config access |
93 | `admin:config:write` | Config write access |93 | `admin:config:write` | Config write access |
94 | `admin:status:read` | Read-only status/health |94 | `admin:status:read` | Read-only status/health |
@@ -143,10 +143,19 @@ gordon auth token generate --subject temp --expiry 30d
143 143
144 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:144 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
145 145
146-- `/admin/*` endpoints are not registered.146+- `/admin/*` endpoints are not registered on the TCP listener.
147 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).147 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
148 - Remote registry and remote admin access are disabled.148 - Remote registry and remote admin access are disabled.
149 149
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+
150 Use this only when Gordon is intended for local machine usage.159 Use this only when Gordon is intended for local machine usage.
151 160
152 ## Internal Registry Auth161 ## 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:
5 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.5 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
6 - **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.6 - **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
7 7
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+
8 ## Database backups12 ## Database backups
9 13
10 ```toml14 ```toml
@@ -64,19 +68,24 @@ keep = 14
64 | `backups.volumes.s3.prefix` | `""` | Object key prefix |68 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
65 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |69 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
66 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |70 | `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 |
68 72
69-Volume backup objects are stored under:73+Volume backup objects are stored under the canonical app name:
70 74
71 ```text75 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip76+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd77+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
74 ```78 ```
75 79
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+
76 ## Notes84 ## Notes
77 85
78-- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.86+- Volume backups archive the app's declared volumes that its backup declaration references.
79-- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.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.
80 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.89 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
81 - Automated volume restore is not part of the MVP.90 - Automated volume restore is not part of the MVP.
82 - 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.91 - 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:
84 93
85 ## Related94 ## Related
86 95
87-- [Attachments Configuration](./attachments.md)96+- [App Manifest](./apps.md)
88 - [CLI Backup Command](../cli/backup.md)97 - [CLI Backup Command](../cli/backup.md)
89 - [Configuration Reference](./reference.md)98 - [Configuration Reference](./reference.md)
M docs/config/env.md +9 -152
@@ -1,6 +1,6 @@
1 # Environment Variables1 # Environment Variables
2 2
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.
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -13,75 +13,17 @@ dir = "~/.gordon/env" # Default location
13 13
14 | Option | Type | Default | Description |14 | Option | Type | Default | Description |
15 |--------|------|---------|-------------|15 |--------|------|---------|-------------|
16-| `dir` | string | `~/.gordon/env` | Directory containing .env files |16+| `dir` | string | `~/.gordon/env` | Directory for the installation secret store |
17 17
18 ## How It Works18 ## How It Works
19 19
20-Gordon loads environment variables from files named after the domain:20+The `[env]` directory backs the installation secret store:
21 21
22-```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-~/.gordon/env/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-├── app_mydomain_com.env # For app.mydomain.com24+- With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
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):
68 25
69-1. **Dockerfile ENV** - Base defaults from image26+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)).
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`
85 27
86 ## Secret Provider Syntax28 ## Secret Provider Syntax
87 29
@@ -110,98 +52,13 @@ STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
110 | pass | `${pass:<path>}` | `${pass:myapp/db-password}` |52 | pass | `${pass:<path>}` | `${pass:myapp/db-password}` |
111 | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` |53 | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` |
112 54
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-
176 ## File Permissions55 ## File Permissions
177 56
178-Gordon creates env files with secure permissions:57+The env directory uses secure permissions:
179 - Directory: `0700` (owner only)58 - Directory: `0700` (owner only)
180 - Files: `0600` (owner only)59 - Files: `0600` (owner only)
181 60
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-
203 ## Related61 ## Related
204 62
205 - [Secrets Configuration](./secrets.md)63 - [Secrets Configuration](./secrets.md)
206-- [Routes](./routes.md)64+- [App Manifest](./apps.md)
207-- [Attachments](./attachments.md)
M docs/config/external-routes.md +1 -1
@@ -79,5 +79,5 @@ vim ~/.config/gordon/gordon.toml
79 79
80 ## Related80 ## Related
81 81
82-- [Routes Configuration](./routes.md)82+- [App Manifest](./apps.md)
83 - [Configuration Overview](./index.md)83 - [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
6 6
7 When enabled, Gordon runs a scheduled image prune job that:7 When enabled, Gordon runs a scheduled image prune job that:
8 8
9-- Prunes dangling runtime images.9+- Prunes dangling runtime images that carry positive Gordon provenance.
10 - Applies tag retention to registry repositories.10 - Applies tag retention to registry repositories.
11 - Preserves the `latest` tag.11 - Preserves the `latest` tag.
12 - Removes unreferenced blobs after tag cleanup.12 - Removes unreferenced blobs after tag cleanup.
13+- Reports every protected and unknown candidate with its reason.
13 14
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.
15 16
16 ## Configuration17 ## Configuration
17 18
18 ```toml19 ```toml
19 [images]20 [images]
20-allowed_registries = []21+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are already allowed.
22+allowed_registries = ["registry.internal:5000"]
21 require_digest = false23 require_digest = false
22 24
23 [images.prune]25 [images.prune]
@@ -30,19 +32,32 @@ keep_last = 3
30 32
31 | Key | Type | Default | Description |33 | Key | Type | Default | Description |
32 |-----|------|---------|-------------|34 |-----|------|---------|-------------|
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"`. |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. |
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. |36+| `images.require_digest` | bool | `false` | Require every image reference, including Gordon registry images, to use a valid `@sha256:<64 hex chars>` digest. |
35 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |37 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
36 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |38 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
37 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |39 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
38 40
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+
39 ## Retention Behavior43 ## Retention Behavior
40 44
41 - `latest` is always preserved.45 - `latest` is always preserved.
42 - `keep_last` applies per repository and counts non-`latest` tags.46 - `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).
44 - Negative `keep_last` values are invalid.49 - Negative `keep_last` values are invalid.
45 50
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+
46 ## Related61 ## Related
47 62
48 - [CLI Images Command](../cli/images.md)63 - [CLI Images Command](../cli/images.md)
M docs/config/index.md +21 -44
@@ -19,12 +19,11 @@ gordon_domain = "gordon.mydomain.com"
19 [entrypoints.edge]19 [entrypoints.edge]
20 address = ":443"20 address = ":443"
21 protocol = "smart_tcp"21 protocol = "smart_tcp"
22-
23-[routes]
24-"app.mydomain.com" = "myapp:latest"
25 ```22 ```
26 23
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.
28 >27 >
29 > 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).28 > 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).
30 29
@@ -87,12 +86,8 @@ max_size = 100 # MB before rotation
87 max_backups = 3 # Old files to keep86 max_backups = 3 # Old files to keep
88 max_age = 28 # Days to keep87 max_age = 28 # Days to keep
89 88
90-[logging.container_logs]89+# Workload logs are streamed directly from the container runtime with
91-enabled = true90+# `gordon apps logs APP --service SERVICE`.
92-dir = "~/.gordon/logs/containers" # Default location
93-max_size = 100
94-max_backups = 3
95-max_age = 28
96 91
97 # Telemetry (OpenTelemetry)92 # Telemetry (OpenTelemetry)
98 [telemetry]93 [telemetry]
@@ -104,7 +99,7 @@ metrics = true # Export metrics
104 logs = true # Bridge zerolog to OTLP logs99 logs = true # Bridge zerolog to OTLP logs
105 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all100 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
106 101
107-# Environment variables102+# Installation secret store location
108 [env]103 [env]
109 dir = "~/.gordon/env" # Default location104 dir = "~/.gordon/env" # Default location
110 105
@@ -114,29 +109,15 @@ auto_create = true # Auto-create from Dockerfile VOLUME
114 prefix = "gordon" # Volume name prefix109 prefix = "gordon" # Volume name prefix
115 preserve = true # Keep volumes on container removal110 preserve = true # Keep volumes on container removal
116 111
117-# Network isolation112+# Installation network policy (prefix filter for `gordon networks list`)
118 [network_isolation]113 [network_isolation]
119-enabled = true # Per-app isolated networks114+enabled = true # Gordon-managed network policy
120 network_prefix = "gordon" # Network name prefix115 network_prefix = "gordon" # Network name prefix
121 internal = false # Set true to block direct egress from isolated networks116 internal = false # Set true to block direct egress from isolated networks
122 117
123-# Auto-route118+# REMOVED in v2.50 (declare apps in standalone files, see ./apps.md):
124-[auto_route]119+# [routes], [attachments], [network_groups], [[services]]-as-apps,
125-enabled = false # Auto-create routes from image names120+# [service_routes], [auto_route], [previews]
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"]
140 121
141 # Backups122 # Backups
142 [backups]123 [backups]
@@ -152,8 +133,8 @@ monthly = 12
152 133
153 # Images134 # Images
154 [images]135 [images]
155-allowed_registries = [] # Explicit external registries allowed for deploy/attachments136+allowed_registries = [] # Additional exact registry hostname+port entries
156-require_digest = false # Require digests for allowlisted external registries137+require_digest = false # Require SHA-256 digests for every image registry
157 138
158 [images.prune]139 [images.prune]
159 enabled = false140 enabled = false
@@ -161,6 +142,8 @@ schedule = "daily"
161 keep_last = 3142 keep_last = 3
162 ```143 ```
163 144
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+
164 ## Configuration Sections147 ## Configuration Sections
165 148
166 | Section | Description | Documentation |149 | Section | Description | Documentation |
@@ -171,16 +154,13 @@ keep_last = 3
171 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |154 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
172 | `[logging]` | Logging configuration | [Logging](./logging.md) |155 | `[logging]` | Logging configuration | [Logging](./logging.md) |
173 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |156 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
174-| `[env]` | Environment variable settings | [Environment](./env.md) |157+| `[env]` | Installation secret store location | [Environment](./env.md) |
175 | `[volumes]` | Volume management | [Volumes](./volumes.md) |158 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
176-| `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |159+| `[network_isolation]` | Installation network policy | [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) |
179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |160 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180 | `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |161 | `[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) |162+| App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) |
182-| `[attachments]` | Service dependencies | [Attachments](./attachments.md) |163+| `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) |
183-| `[backups]` | Database backups | [Backups](./backups.md) |
184 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |164 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
185 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |165 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
186 166
@@ -214,13 +194,11 @@ keep_last = 3
214 | `logging.file.max_size` | `100` |194 | `logging.file.max_size` | `100` |
215 | `logging.file.max_backups` | `3` |195 | `logging.file.max_backups` | `3` |
216 | `logging.file.max_age` | `28` |196 | `logging.file.max_age` | `28` |
217-| `logging.container_logs.enabled` | `true` |
218 | `volumes.auto_create` | `true` |197 | `volumes.auto_create` | `true` |
219 | `volumes.prefix` | `"gordon"` |198 | `volumes.prefix` | `"gordon"` |
220 | `volumes.preserve` | `true` |199 | `volumes.preserve` | `true` |
221 | `network_isolation.enabled` | `true` |200 | `network_isolation.enabled` | `true` |
222 | `network_isolation.internal` | `false` |201 | `network_isolation.internal` | `false` |
223-| `auto_route.enabled` | `false` |
224 | `backups.enabled` | `false` |202 | `backups.enabled` | `false` |
225 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
226 | `images.allowed_registries` | `[]` |204 | `images.allowed_registries` | `[]` |
@@ -236,7 +214,7 @@ keep_last = 3
236 | `telemetry.logs` | `true` |214 | `telemetry.logs` | `true` |
237 | `telemetry.trace_sample_rate` | `1.0` |215 | `telemetry.trace_sample_rate` | `1.0` |
238 216
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).
240 218
241 ## Hot Reload219 ## Hot Reload
242 220
@@ -256,7 +234,6 @@ gordon reload
256 | `server.max_proxy_response_size` |234 | `server.max_proxy_response_size` |
257 | `server.max_concurrent_conns` |235 | `server.max_concurrent_conns` |
258 236
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.
260 237
261 ### Requires restart238 ### Requires restart
262 239
@@ -286,7 +263,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
286 ## Related263 ## Related
287 264
288 - [Server Configuration](./server.md)265 - [Server Configuration](./server.md)
289-- [Routes Configuration](./routes.md)266+- [App Manifest](./apps.md)
290 - [External Routes](./external-routes.md)267 - [External Routes](./external-routes.md)
291 - [Standalone Services](./services.md)268 - [Standalone Services](./services.md)
292 - [Traffic Plane](./traffic.md)269 - [Traffic Plane](./traffic.md)
M docs/config/logging.md +21 -92
@@ -1,6 +1,6 @@
1 # Logging Configuration1 # Logging Configuration
2 2
3-Configure application and container log collection.3+Configure Gordon process and HTTP access logging. Workload logs are read directly from the container runtime.
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -16,13 +16,6 @@ max_size = 100
16 max_backups = 316 max_backups = 3
17 max_age = 2817 max_age = 28
18 18
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-
26 [logging.access_log]19 [logging.access_log]
27 enabled = false20 enabled = false
28 format = "json"21 format = "json"
@@ -44,24 +37,19 @@ syslog_identifier = "gordon-access"
44 37
45 | Option | Type | Default | Description |38 | Option | Type | Default | Description |
46 |--------|------|---------|-------------|39 |--------|------|---------|-------------|
47-| `file.enabled` | bool | `false` | Enable file-based logging |40+| `file.enabled` | bool | `false` | Enable process file logging |
48-| `file.path` | string | - | Path to main log file |41+| `file.path` | string | `{data_dir}/logs/gordon.log` | Process log path |
49 | `file.max_size` | int | `100` | Max file size in MB before rotation |42 | `file.max_size` | int | `100` | Max file size in MB before rotation |
50 | `file.max_backups` | int | `3` | Number of old files to keep |43 | `file.max_backups` | int | `3` | Number of old files to keep |
51 | `file.max_age` | int | `28` | Days to keep old files |44 | `file.max_age` | int | `28` | Days to keep old files |
52 45
53-The Admin API and `gordon logs` read from the process log file. Keep46+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.
54-`logging.file.enabled` set to `true` if you need process log streaming.
55 47
56-### Container Logs48+### Workload Logs
57 49
58-| Option | Type | Default | Description |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.
59-|--------|------|---------|-------------|51+
60-| `container_logs.enabled` | bool | `true` | Collect container stdout/stderr |52+The accepted `logging.container_logs` configuration fields are currently not connected to a production log sink and do not create files.
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 |
65 53
66 ### Access Log54 ### Access Log
67 55
@@ -89,31 +77,9 @@ Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingesti
89 | `warn` | Warnings |77 | `warn` | Warnings |
90 | `error` | Errors only |78 | `error` | Errors only |
91 79
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-
104 ## Log Rotation80 ## Log Rotation
105 81
106-Gordon uses automatic log rotation:82+Process file logs and file-based access logs rotate by size and retain files by count and age. Old files are compressed.
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
117 83
118 ## Examples84 ## Examples
119 85
@@ -131,13 +97,6 @@ max_size = 10
131 max_backups = 297 max_backups = 2
132 max_age = 798 max_age = 7
133 99
134-[logging.container_logs]
135-enabled = true
136-dir = "./logs/containers"
137-max_size = 10
138-max_backups = 2
139-max_age = 7
140-
141 [logging.access_log]100 [logging.access_log]
142 enabled = true101 enabled = true
143 format = "json"102 format = "json"
@@ -160,12 +119,11 @@ max_size = 100
160 max_backups = 10119 max_backups = 10
161 max_age = 90120 max_age = 90
162 121
163-[logging.container_logs]122+[logging.access_log]
164 enabled = true123 enabled = true
165-dir = "~/.gordon/logs/containers"124+format = "json"
166-max_size = 100125+output = "journald"
167-max_backups = 10126+syslog_identifier = "gordon-access"
168-max_age = 90
169 ```127 ```
170 128
171 ### Minimal (Console Only)129 ### Minimal (Console Only)
@@ -177,60 +135,31 @@ format = "console"
177 135
178 [logging.file]136 [logging.file]
179 enabled = false137 enabled = false
180-
181-[logging.container_logs]
182-enabled = true
183 ```138 ```
184 139
185 ## Viewing Logs140 ## Viewing Logs
186 141
187-### Gordon Logs142+### Gordon Process Logs
188 143
189 ```bash144 ```bash
190-# Using gordon logs command145+gordon logs -f
191-gordon logs -f # Follow logs146+gordon logs -n 100
192-gordon logs -n 100 # Last 100 lines
193-
194-# Direct file access
195 tail -f ~/.gordon/logs/gordon.log147 tail -f ~/.gordon/logs/gordon.log
196-
197-# With journalctl (if using systemd)
198 journalctl --user -u gordon -f148 journalctl --user -u gordon -f
199 ```149 ```
200 150
201-### Container Logs151+### Workload Logs
202 152
203 ```bash153 ```bash
204-# View container logs154+gordon apps logs blog --service web
205-tail -f ~/.gordon/logs/containers/app_mydomain_com.log155+gordon apps logs blog --service web --follow
206-
207-# All container logs
208-ls ~/.gordon/logs/containers/
209 ```156 ```
210 157
211-## Log Format158+These commands stream from the container runtime; they do not read Gordon-managed workload log files.
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-```
226 159
227 ## Security160 ## Security
228 161
229-Log files are created with secure permissions: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.
230-- Directories: `0700` (owner only)
231-- Files: `0600` (owner only)
232-
233-Sensitive values (passwords, tokens) are automatically redacted.
234 163
235 ## Related164 ## Related
236 165
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 @@
1 # Network Isolation1 # Network Isolation
2 2
3-Isolate applications in separate Docker networks for enhanced security.3+Installation network policy for Gordon-managed networks.
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -11,177 +11,32 @@ network_prefix = "gordon"
11 internal = false11 internal = false
12 ```12 ```
13 13
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-
24 ## Options14 ## Options
25 15
26 | Option | Type | Default | Description |16 | Option | Type | Default | Description |
27 |--------|------|---------|-------------|17 |--------|------|---------|-------------|
28-| `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |18+| `enabled` | bool | `true` | Enable Gordon-managed network policy |
29-| `network_prefix` | string | `"gordon"` | Prefix for created networks |19+| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` |
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. |20+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
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-```
163 21
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.
165 23
166 ## Inspecting Networks24 ## Inspecting Networks
167 25
168-View created networks:26+View Gordon-managed networks:
169 27
170 ```bash28 ```bash
29+gordon networks list
171 docker network ls | grep gordon30 docker network ls | grep gordon
172-# gordon-app-mydomain-com
173-# gordon-api-mydomain-com
174-# gordon-backend
175 ```31 ```
176 32
177 Inspect a network:33 Inspect a network:
178 34
179 ```bash35 ```bash
180-docker network inspect gordon-app-mydomain-com36+docker network inspect <network-name>
181 ```37 ```
182 38
183 ## Related39 ## Related
184 40
185-- [Network Groups](./network-groups.md)41+- [App Manifest](./apps.md)
186-- [Attachments](./attachments.md)
187 - [Configuration Overview](./index.md)42 - [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
83 max_backups = 3 # Number of old files to keep83 max_backups = 3 # Number of old files to keep
84 max_age = 28 # Days to keep old files84 max_age = 28 # Days to keep old files
85 85
86-[logging.container_logs]86+# Workload logs are streamed from the container runtime with
87-enabled = true # Enable container log collection87+# `gordon apps logs APP --service SERVICE`. The accepted
88-dir = "" # Log directory (default: {data_dir}/container-logs)88+# logging.container_logs fields are not connected to a production file sink.
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
92 89
93 [logging.access_log]90 [logging.access_log]
94 enabled = false # Dedicated HTTP access log for reverse-proxy traffic91 enabled = false # Dedicated HTTP access log for reverse-proxy traffic
@@ -137,18 +134,12 @@ drain_delay = "2s" # Wait after cache invalidation bef
137 [containers]134 [containers]
138 security_profile = "compat" # "compat" or "strict"135 security_profile = "compat" # "compat" or "strict"
139 136
140-# =============================================================================
141-# AUTO-ROUTE
142-# =============================================================================
143-[auto_route]
144-enabled = false # Create routes from image labels automatically
145-
146 # =============================================================================137 # =============================================================================
147 # NETWORK ISOLATION138 # NETWORK ISOLATION
148 # =============================================================================139 # =============================================================================
149 [network_isolation]140 [network_isolation]
150-enabled = true # Enable per-app Docker networks141+enabled = true # Installation network policy for Gordon-managed networks
151-network_prefix = "gordon" # Prefix for created networks142+network_prefix = "gordon" # Prefix filter for `gordon networks list`
152 internal = false # Create Docker internal networks (blocks direct egress)143 internal = false # Create Docker internal networks (blocks direct egress)
153 144
154 # =============================================================================145 # =============================================================================
@@ -159,13 +150,9 @@ auto_create = true # Auto-create volumes from Dockerfi
159 prefix = "gordon" # Volume name prefix150 prefix = "gordon" # Volume name prefix
160 preserve = true # Keep volumes when containers are removed151 preserve = true # Keep volumes when containers are removed
161 152
162-# =============================================================================153+# REMOVED in v2.50: [routes], [attachments], [network_groups],
163-# ROUTES154+# [[services]]-as-apps, [service_routes], [auto_route], [previews].
164-# =============================================================================155+# Declare apps in standalone files (see ./apps.md).
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.
169 156
170 # =============================================================================157 # =============================================================================
171 # EXTERNAL ROUTES158 # EXTERNAL ROUTES
@@ -195,18 +182,6 @@ preserve = true # Keep volumes when containers are
195 # publish = "127.0.0.1:38016"182 # publish = "127.0.0.1:38016"
196 # trusted_cidrs = ["100.64.0.0/10"]183 # trusted_cidrs = ["100.64.0.0/10"]
197 184
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-
210 # =============================================================================185 # =============================================================================
211 # BACKUPS186 # BACKUPS
212 # =============================================================================187 # =============================================================================
@@ -225,6 +200,8 @@ monthly = 0 # Keep N monthly backups per DB
225 # IMAGES200 # IMAGES
226 # =============================================================================201 # =============================================================================
227 [images]202 [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.
228 allowed_registries = []205 allowed_registries = []
229 require_digest = false206 require_digest = false
230 207
@@ -265,7 +242,7 @@ keep_last = 3 # Keep N newest tags per repository
265 | `tls.acme.email` | `""` | ACME account email when enabled |242 | `tls.acme.email` | `""` | ACME account email when enabled |
266 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |243 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
267 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |244 | `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`) |
269 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |246 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
270 | `auth.token_expiry` | `"30d"` | 30 days |247 | `auth.token_expiry` | `"30d"` | 30 days |
271 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |248 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
@@ -280,10 +257,6 @@ keep_last = 3 # Keep N newest tags per repository
280 | `logging.file.max_size` | `100` | 100 MB |257 | `logging.file.max_size` | `100` | 100 MB |
281 | `logging.file.max_backups` | `3` | Keep 3 old files |258 | `logging.file.max_backups` | `3` | Keep 3 old files |
282 | `logging.file.max_age` | `28` | 28 days |259 | `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 |
287 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |260 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
288 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |261 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
289 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |262 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
@@ -307,7 +280,6 @@ keep_last = 3 # Keep N newest tags per repository
307 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |280 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
308 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |281 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
309 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |282 | `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 |
311 | `network_isolation.enabled` | `true` | Network isolation enabled |283 | `network_isolation.enabled` | `true` | Network isolation enabled |
312 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |284 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
313 | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |285 | `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
342 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |314 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
343 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |315 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
344 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |316 | `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 |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. |
346-| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |318+| `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
347 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |319 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
348 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |320 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
349 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |321 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
@@ -419,5 +391,6 @@ gordon serve
419 - [Telemetry](./telemetry.md)391 - [Telemetry](./telemetry.md)
420 - [Network Isolation](./network-isolation.md)392 - [Network Isolation](./network-isolation.md)
421 - [Volumes](./volumes.md)393 - [Volumes](./volumes.md)
394+- [App Manifest](./apps.md)
422 - [Standalone Services](./services.md)395 - [Standalone Services](./services.md)
423 - [Images](./images.md)396 - [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 @@
1 # Secrets Configuration1 # Secrets Configuration
2 2
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>`).
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -52,11 +52,9 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
52 - Standard Unix tooling52 - Standard Unix tooling
53 - Works with team GPG keys53 - Works with team GPG keys
54 54
55-**Route secrets storage:**55+**Installation secrets storage:**
56-- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)56+- `gordon secrets set <domain> --from-file` 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`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
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
60 58
61 ### SOPS59 ### SOPS
62 60
@@ -79,7 +77,6 @@ sops secrets.yaml
79 77
80 **Usage in env files:**78 **Usage in env files:**
81 ```bash79 ```bash
82-# ~/.gordon/env/app_mydomain_com.env
83 API_SECRET=${sops:secrets.yaml:api.secret}80 API_SECRET=${sops:secrets.yaml:api.secret}
84 DB_PASSWORD=${sops:secrets.yaml:database.password}81 DB_PASSWORD=${sops:secrets.yaml:database.password}
85 ```82 ```
@@ -94,15 +91,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
94 - Path traversal (`..`) is blocked91 - Path traversal (`..`) is blocked
95 - Only relative paths from your config directory are allowed92 - Only relative paths from your config directory are allowed
96 93
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-
106 ### Unsafe (Development Only)94 ### Unsafe (Development Only)
107 95
108 Stores secrets as plain text files:96 Stores secrets as plain text files:
@@ -120,11 +108,6 @@ secrets_backend = "unsafe"
120 │ └── token_secret108 │ └── token_secret
121 ```109 ```
122 110
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-
128 **Usage:**111 **Usage:**
129 ```bash112 ```bash
130 # Create secret113 # Create secret
@@ -151,9 +134,19 @@ API_KEY=${pass:myapp/api-key}
151 ```bash134 ```bash
152 # ${sops:<file>:<key.path>}135 # ${sops:<file>:<key.path>}
153 DATABASE_PASSWORD=${sops:secrets.yaml:database.password}136 DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
154-API_SECRET=${sops:production.yaml:api.secret.key}137+API_SECRET=${sops:production.yaml:api.key}
155 ```138 ```
156 139
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+
157 ## Examples150 ## Examples
158 151
159 ### Production with Pass152 ### Production with Pass
@@ -182,29 +175,10 @@ enabled = false
182 secrets_backend = "unsafe"175 secrets_backend = "unsafe"
183 ```176 ```
184 177
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-
204 ## Security Recommendations178 ## Security Recommendations
205 179
206 1. **Production**: Always use `pass` or `sops` backend180 1. **Production**: Always use `pass` or `sops` backend
207-2. **Never commit**: Don't commit unencrypted secrets to git181+2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values)
208 3. **Rotate regularly**: Regenerate tokens and passwords periodically182 3. **Rotate regularly**: Regenerate tokens and passwords periodically
209 4. **Least privilege**: Use separate secrets per environment183 4. **Least privilege**: Use separate secrets per environment
210 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security184 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}
212 ## Related186 ## Related
213 187
214 - [Authentication](./auth.md)188 - [Authentication](./auth.md)
215-- [Environment Variables](./env.md)189+- [App Manifest](./apps.md)
216 - [Configuration Overview](./index.md)190 - [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
24 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d24 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
25 ```25 ```
26 26
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`.
28 - `admin:status:read` does not grant log access.28 - `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.
30 31
31 ## Volume pruning scope32 ## Volume pruning scope
32 33
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.
34 37
35 Use dedicated admin scopes:38 Use dedicated admin scopes:
36 39
37 - `admin:volumes:read` for listing volumes.40 - `admin:volumes:read` for listing volumes.
38 - `admin:volumes:write` for prune operations.41 - `admin:volumes:write` for prune operations.
39 42
40-## Pass migration plaintext handling43+## Pass import plaintext handling
41 44
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.
43 46
44 ## External image registries47 ## External image registries
45 48
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:
47 50
48 ```toml51 ```toml
49 [images]52 [images]
50-allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"]53+allowed_registries = ["registry.internal:5000"]
51 require_digest = true54 require_digest = true
52 ```55 ```
53 56
54-- Empty `allowed_registries` rejects explicit external registries.57+- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
55-- `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected.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.
56-- `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.59+- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
57-- Include ports in allowlist entries when the registry uses a non-default port.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.
58 61
59 ## Smart TCP Raw Fallback62 ## Smart TCP Raw Fallback
60 63
@@ -95,7 +98,16 @@ enabled = true
95 internal = true98 internal = true
96 ```99 ```
97 100
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.
99 111
100 ## Container runtime profile112 ## Container runtime profile
101 113
M docs/config/server.md +8 -11
@@ -102,7 +102,7 @@ Challenge behavior:
102 102
103 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.103 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.
104 104
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.
106 106
107 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.107 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.
108 108
@@ -116,7 +116,7 @@ DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS vis
116 116
117 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.117 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.
118 118
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.
120 120
121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
122 122
@@ -128,7 +128,7 @@ When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (
128 128
129 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.129 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.
130 130
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.
132 132
133 #### HTTP to HTTPS redirect133 #### HTTP to HTTPS redirect
134 134
@@ -187,7 +187,7 @@ gordon_domain = "gordon.mydomain.com"
187 This domain is used for:187 This domain is used for:
188 - Docker login and image push/pull operations188 - Docker login and image push/pull operations
189 - Admin API access (`/admin/*` endpoints)189 - 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`)
191 - Authentication endpoints (`/auth/*`)191 - Authentication endpoints (`/auth/*`)
192 192
193 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.193 > **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
220 ├── registry/ # Container images and manifests220 ├── registry/ # Container images and manifests
221 │ ├── blobs/221 │ ├── blobs/
222 │ └── manifests/222 │ └── manifests/
223-├── env/ # Environment files per domain223+├── env/ # Installation secret-store data
224-│ ├── app_mydomain_com.env224+├── logs/ # Gordon process and file-based access logs
225-│ └── api_mydomain_com.env
226-├── logs/ # Application and container logs
227 │ ├── gordon.log225 │ ├── gordon.log
228-│ ├── proxy.log226+│ └── access.log
229-│ └── containers/
230 └── secrets/ # Secrets (unsafe backend only)227 └── secrets/ # Secrets (unsafe backend only)
231 ```228 ```
232 229
@@ -430,6 +427,6 @@ sudo firewall-cmd --reload
430 427
431 - [Configuration Overview](./index.md)428 - [Configuration Overview](./index.md)
432 - [Installation Guide](../installation.md)429 - [Installation Guide](../installation.md)
433-- [Routes Configuration](./routes.md)430+- [App Manifest](./apps.md)
434 - [Standalone Services](./services.md)431 - [Standalone Services](./services.md)
435 - [Traffic Plane Configuration](./traffic.md)432 - [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
4 4
5 ## Smart TCP Edge Entrypoint5 ## Smart TCP Edge Entrypoint
6 6
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:
8 8
9 ```toml9 ```toml
10 [entrypoints.edge]10 [entrypoints.edge]
@@ -15,7 +15,7 @@ trusted_cidrs = []
15 15
16 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.16 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.
17 17
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.
19 19
20 ## Smart TCP Dispatch Order20 ## Smart TCP Dispatch Order
21 21
@@ -73,7 +73,7 @@ protocol = "tcp"
73 73
74 Service references use:74 Service references use:
75 75
76-- `route:<domain>` for configured HTTP routes76+- `route:<domain>` for app HTTP hosts from ACTIVE state (merged with installation external routes)
77 - `external_route:<domain>` for configured external HTTP routes77 - `external_route:<domain>` for configured external HTTP routes
78 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends78 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends
79 - `service:<service>:<port-name>` for Gordon-managed standalone service backends79 - `service:<service>:<port-name>` for Gordon-managed standalone service backends
@@ -96,7 +96,7 @@ sni = "raw.example.com"
96 service = "network_service:raw:tls"96 service = "network_service:raw:tls"
97 ```97 ```
98 98
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.
100 100
101 ## Raw TCP Fallback101 ## Raw TCP Fallback
102 102
@@ -176,6 +176,6 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
176 ## Related176 ## Related
177 177
178 - [Server Settings](./server.md)178 - [Server Settings](./server.md)
179-- [Routes](./routes.md)179+- [App Manifest](./apps.md)
180 - [Standalone Services](./services.md)180 - [Standalone Services](./services.md)
181 - [CLI traffic status](../cli/traffic.md)181 - [CLI traffic status](../cli/traffic.md)
M docs/config/volumes.md +29 -137
@@ -1,165 +1,57 @@
1 # Volumes Configuration1 # Volumes Configuration
2 2
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.
4 4
5-## Configuration5+## Declarative app volumes
6 6
7-```toml7+Services declare persistent mounts in the app manifest:
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
105 8
106 ```toml9 ```toml
107-[volumes]10+[[service.volume]]
108-prefix = "prod"11+name = "database-data"
12+path = "/var/lib/postgresql/data"
109 ```13 ```
110 14
111-Volume names become: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.
112-- `prod-app-mydomain-com-data`
113-- `prod-db-mydomain-com-var-lib-postgresql-data`
114 16
115-## Managing Volumes17+Every Dockerfile `VOLUME` path must have a matching `[[service.volume]]` declaration. Deployment fails closed when an image declares an unmanaged volume.
116 18
117-### List Volumes19+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.
118 20
119-```bash21+## Retention
120-docker volume ls | grep gordon
121-```
122 22
123-### Inspect Volume23+Ordinary app operations retain data:
124 24
125-```bash25+- deploy and restart reuse the service's volumes;
126-docker volume inspect gordon-app-mydomain-com-data26+- removing a service retains its volumes;
127-```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.
128 29
129-### Backup Volume30+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.
130 31
131-```bash32+## Pruning
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-```
137 33
138-### Restore Volume34+Use Gordon's ownership-aware command:
139 35
140 ```bash36 ```bash
141-docker run --rm \37+gordon volumes prune --dry-run
142- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \38+gordon volumes prune
143- -v $(pwd):/backup \
144- alpine tar -xzf /backup/db-backup.tar.gz -C /data
145 ```39 ```
146 40
147-## Volume Cleanup41+A volume is eligible only when all of these requirements hold:
148 42
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.
150 46
151-```bash47+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.
152-# List all gordon volumes
153-docker volume ls -f name=gordon
154 48
155-# Remove specific volume (warning: deletes 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.
156-docker volume rm gordon-old-app-data
157 50
158-# Prune unused volumes (be careful!)51+See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output.
159-docker volume prune
160-```
161 52
162 ## Related53 ## Related
163 54
164-- [Attachments](./attachments.md)55+- [App Manifest](./apps.md)
56+- [Volumes CLI](../cli/volumes.md)
165 - [Configuration Overview](./index.md)57 - [Configuration Overview](./index.md)
M docs/deployment/generic-ci.md +16 -16
@@ -5,7 +5,7 @@ Deploy with Gordon from any CI/CD system.
5 ## Requirements5 ## Requirements
6 6
7 - Docker available on CI runner (for building images)7 - 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`
9 - Gordon binary (optional but recommended)9 - Gordon binary (optional but recommended)
10 10
11 ## Recommended: gordon push11 ## Recommended: gordon push
@@ -17,7 +17,7 @@ On your Gordon server:
17 ```bash17 ```bash
18 gordon auth token generate \18 gordon auth token generate \
19 --subject ci-deploy \19 --subject ci-deploy \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
21 --expiry 021 --expiry 0
22 ```22 ```
23 23
@@ -42,7 +42,7 @@ chmod +x /usr/local/bin/gordon
42 export GORDON_TOKEN="$GORDON_TOKEN"42 export GORDON_TOKEN="$GORDON_TOKEN"
43 gordon push --build \43 gordon push --build \
44 --remote "$GORDON_REMOTE" \44 --remote "$GORDON_REMOTE" \
45- --no-confirm45+
46 ```46 ```
47 47
48 Gordon auto-detects the version from CI environment variables:48 Gordon auto-detects the version from CI environment variables:
@@ -69,7 +69,7 @@ docker push gordon.example.com/myapp:v1.0.0
69 ```69 ```
70 70
71 This requires the token subject (`ci-deploy`) as the username.71 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.
73 73
74 ## CI System Examples74 ## CI System Examples
75 75
@@ -89,7 +89,7 @@ pipeline {
89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
90 -o /usr/local/bin/gordon90 -o /usr/local/bin/gordon
91 chmod +x /usr/local/bin/gordon91 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE" --no-confirm92+ gordon push --build --remote "$GORDON_REMOTE"
93 '''93 '''
94 }94 }
95 }95 }
@@ -120,8 +120,7 @@ jobs:
120 name: Deploy120 name: Deploy
121 command: |121 command: |
122 gordon push --build \122 gordon push --build \
123- --remote "$GORDON_REMOTE" \123+ --remote "$GORDON_REMOTE"
124- --no-confirm
125 124
126 workflows:125 workflows:
127 deploy:126 deploy:
@@ -153,7 +152,7 @@ steps:
153 - apk add --no-cache curl152 - apk add --no-cache curl
154 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155 - chmod +x /usr/local/bin/gordon154 - chmod +x /usr/local/bin/gordon
156- - gordon push --build --remote "$GORDON_REMOTE" --no-confirm155+ - gordon push --build --remote "$GORDON_REMOTE"
157 156
158 trigger:157 trigger:
159 event:158 event:
@@ -162,11 +161,13 @@ trigger:
162 161
163 ## Token Scopes Reference162 ## Token Scopes Reference
164 163
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+
165 | Workflow | Required Scopes |166 | Workflow | Required Scopes |
166 |----------|----------------|167 |----------|----------------|
167-| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |168+| Build + push, then apply + deploy | `push,pull,admin:apps:read,admin:apps:write` |
168-| Push only (no deploy) | `push,pull,admin:routes:read` |169+| Push only (no deploy) | `push,pull` |
169-| docker push (auto-deploy) | `push,pull` |170+| docker push, then apply + deploy | `push,pull` + `admin:apps:read,admin:apps:write` for the CLI step |
170 171
171 ## Troubleshooting172 ## Troubleshooting
172 173
@@ -174,14 +175,13 @@ trigger:
174 175
175 The token is invalid or has been revoked. Generate a new one.176 The token is invalid or has been revoked. Generate a new one.
176 177
177-### "no route configured for image"178+### "deploy target not found" / app has no desired state
178 179
179-The route must exist before pushing. Create it with:180+Push only stores the image. Declare the app first:
180 181
181 ```bash182 ```bash
182-gordon routes add myapp.example.com myapp183+gordon apps apply --file myapp.toml --remote "$GORDON_REMOTE"
183-# or for first deploy:184+gordon apps deploy myapp --remote "$GORDON_REMOTE"
184-gordon bootstrap myapp.example.com myapp
185 ```185 ```
186 186
187 ### Version shows "latest"187 ### 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
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7-1. Gordon server running with registry authentication enabled7+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 required scopes8+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
9 3. GitHub repository secrets configured9 3. GitHub repository secrets configured
10 10
11 ## Recommended: gordon push11 ## Recommended: gordon push
@@ -19,7 +19,7 @@ On your Gordon server:
19 ```bash19 ```bash
20 gordon auth token generate \20 gordon auth token generate \
21 --subject github-actions \21 --subject github-actions \
22- --scopes "push,pull,admin:routes:read,admin:config:write" \22+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
23 --expiry 023 --expiry 0
24 ```24 ```
25 25
@@ -72,8 +72,7 @@ jobs:
72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
73 run: |73 run: |
74 gordon push --build \74 gordon push --build \
75- --remote ${{ secrets.GORDON_REMOTE }} \75+ --remote ${{ secrets.GORDON_REMOTE }}
76- --no-confirm
77 ```76 ```
78 77
79 #### Continuous Deploy on Main78 #### Continuous Deploy on Main
@@ -105,8 +104,7 @@ jobs:
105 run: |104 run: |
106 gordon push --build \105 gordon push --build \
107 --remote ${{ secrets.GORDON_REMOTE }} \106 --remote ${{ secrets.GORDON_REMOTE }} \
108- --tag latest \107+ --tag latest
109- --no-confirm
110 ```108 ```
111 109
112 #### Manual Dispatch110 #### Manual Dispatch
@@ -141,7 +139,7 @@ jobs:
141 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}139 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142 DEPLOY_TAG: ${{ inputs.tag }}140 DEPLOY_TAG: ${{ inputs.tag }}
143 run: |141 run: |
144- args=(push --build --remote "$GORDON_REMOTE" --no-confirm)142+ args=(push --build --remote "$GORDON_REMOTE")
145 if [[ -n "$DEPLOY_TAG" ]]; then143 if [[ -n "$DEPLOY_TAG" ]]; then
146 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1144 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
147 args+=(--tag "$DEPLOY_TAG")145 args+=(--tag "$DEPLOY_TAG")
@@ -176,8 +174,7 @@ jobs:
176 gordon push --build \174 gordon push --build \
177 --remote ${{ secrets.GORDON_REMOTE }} \175 --remote ${{ secrets.GORDON_REMOTE }} \
178 --file ./services/api/Dockerfile \176 --file ./services/api/Dockerfile \
179- myapp-api \177+ myapp-api
180- --no-confirm
181 178
182 - name: Deploy Web179 - name: Deploy Web
183 env:180 env:
@@ -186,8 +183,7 @@ jobs:
186 gordon push --build \183 gordon push --build \
187 --remote ${{ secrets.GORDON_REMOTE }} \184 --remote ${{ secrets.GORDON_REMOTE }} \
188 --file ./services/web/Dockerfile \185 --file ./services/web/Dockerfile \
189- myapp-web \186+ myapp-web
190- --no-confirm
191 ```187 ```
192 188
193 #### With Build Args189 #### With Build Args
@@ -204,7 +200,7 @@ Pass build arguments to the Docker build:
204 --build-arg NODE_ENV=production \200 --build-arg NODE_ENV=production \
205 --build-arg API_URL=https://api.example.com \201 --build-arg API_URL=https://api.example.com \
206 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \202 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
207- --no-confirm203+
208 ```204 ```
209 205
210 ### setup-gordon Action Reference206 ### setup-gordon Action Reference
@@ -220,7 +216,7 @@ Pass build arguments to the Docker build:
220 216
221 ## Alternative: Docker-based Workflow217 ## Alternative: Docker-based Workflow
222 218
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.
224 220
225 ### Setup221 ### Setup
226 222
M docs/deployment/gitlab-ci.md +9 -9
@@ -4,8 +4,8 @@ Automated deployment with GitLab CI/CD using Gordon's push command.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7-1. Gordon server running with registry authentication enabled7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8-2. Generated deployment token8+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
9 3. GitLab CI/CD variables configured9 3. GitLab CI/CD variables configured
10 10
11 ## Quick Setup11 ## Quick Setup
@@ -17,7 +17,7 @@ On your Gordon server:
17 ```bash17 ```bash
18 gordon auth token generate \18 gordon auth token generate \
19 --subject gitlab-ci \19 --subject gitlab-ci \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
21 --expiry 021 --expiry 0
22 ```22 ```
23 23
@@ -48,7 +48,7 @@ deploy:
48 - gordon push --build48 - gordon push --build
49 --remote "$GORDON_REMOTE"49 --remote "$GORDON_REMOTE"
50 --token "$GORDON_TOKEN"50 --token "$GORDON_TOKEN"
51- --no-confirm51+
52 only:52 only:
53 - tags53 - tags
54 ```54 ```
@@ -76,7 +76,7 @@ deploy:
76 - gordon push --build76 - gordon push --build
77 --remote "$GORDON_REMOTE"77 --remote "$GORDON_REMOTE"
78 --token "$GORDON_TOKEN"78 --token "$GORDON_TOKEN"
79- --no-confirm79+
80 rules:80 rules:
81 - if: $CI_COMMIT_TAG81 - if: $CI_COMMIT_TAG
82 ```82 ```
@@ -99,7 +99,7 @@ deploy:
99 - gordon push --build99 - gordon push --build
100 --remote "$GORDON_REMOTE"100 --remote "$GORDON_REMOTE"
101 --token "$GORDON_TOKEN"101 --token "$GORDON_TOKEN"
102- --no-confirm102+
103 rules:103 rules:
104 - if: $CI_COMMIT_BRANCH == "main"104 - if: $CI_COMMIT_BRANCH == "main"
105 ```105 ```
@@ -120,7 +120,7 @@ deploy:
120 - gordon push --build120 - gordon push --build
121 --remote "$GORDON_REMOTE"121 --remote "$GORDON_REMOTE"
122 --token "$GORDON_TOKEN"122 --token "$GORDON_TOKEN"
123- --no-confirm123+
124 when: manual124 when: manual
125 ```125 ```
126 126
@@ -142,7 +142,7 @@ deploy:
142 --token "$GORDON_TOKEN"142 --token "$GORDON_TOKEN"
143 --build-arg NODE_ENV=production143 --build-arg NODE_ENV=production
144 --build-arg API_URL=https://api.example.com144 --build-arg API_URL=https://api.example.com
145- --no-confirm145+
146 rules:146 rules:
147 - if: $CI_COMMIT_TAG147 - if: $CI_COMMIT_TAG
148 ```148 ```
@@ -166,7 +166,7 @@ deploy:
166 ```166 ```
167 167
168 This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.168 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.
170 170
171 ## Version Detection171 ## Version Detection
172 172
M docs/deployment/index.md +42 -68
@@ -1,78 +1,68 @@
1 # Deployment Overview1 # Deployment Overview
2 2
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.
4 4
5-## Recommended: gordon push5+## Recommended: gordon push + apps deploy
6 6
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.
8 8
9-- Single command: build + push + deploy
10 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token9 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
11 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)10 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
12 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies11 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
13 12
14 ```bash13 ```bash
15-gordon push --build --remote https://gordon.example.com --no-confirm14+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
16 ```22 ```
17 23
18 ## All Deployment Methods24 ## All Deployment Methods
19 25
20 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |26 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
21 |--------|----------|----------------|-----------------|----------------|27 |--------|----------|----------------|-----------------|----------------|
22-| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |28+| `gordon push` + `apps deploy` (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) |29+| `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
24-| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
25 30
26-### Method 1: gordon push (Recommended)31+### Method 1: gordon push + apps deploy (Recommended)
27 32
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.
29 34
30 - Single token handles everything: admin API access + registry auth via automatic token exchange35 - Single token handles everything: admin API access + registry auth via automatic token exchange
31 - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`36 - 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)
33 - Requires the Gordon binary on the CI runner37 - Requires the Gordon binary on the CI runner
34 38
35 ```bash39 ```bash
36-# Build, push, and deploy40+# Build and push (OCI transfer only)
37-gordon push --build --remote https://gordon.example.com --no-confirm41+gordon push --build --remote https://gordon.example.com
38 42
39-# Push only, no deploy43+# Apply the manifest that references the pushed tag, then deploy
40-gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm44+gordon apps apply --file blog.toml --remote https://gordon.example.com
45+gordon apps deploy blog --remote https://gordon.example.com
41 ```46 ```
42 47
43-### Method 2: docker push48+### Method 2: docker push + apps deploy
44 49
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.
46 51
47 - Use `docker login`, `docker build`, and `docker push` as usual52 - 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)53+- Pushing only stores the image; deploy explicitly with the Gordon CLI afterwards
49-- No Gordon binary needed on the CI runner
50 - Registry endpoint is `gordon.example.com` (not a separate registry host)54 - Registry endpoint is `gordon.example.com` (not a separate registry host)
51 55
52 ```bash56 ```bash
53 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com57 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
54 docker build -t gordon.example.com/myapp:v1.2.0 .58 docker build -t gordon.example.com/myapp:v1.2.0 .
55 docker push gordon.example.com/myapp:v1.2.059 docker push gordon.example.com/myapp:v1.2.0
60+# Then: gordon apps apply --file blog.toml + gordon apps deploy blog
56 ```61 ```
57 62
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-
73 ## Registry Access63 ## Registry Access
74 64
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.
76 66
77 ### Network Topologies67 ### Network Topologies
78 68
@@ -87,34 +77,27 @@ Gordon's registry is served through the main gordon domain over HTTPS on port 44
87 See the examples below for the right scopes for each workflow.77 See the examples below for the right scopes for each workflow.
88 78
89 ```bash79 ```bash
90-# Minimum scope for gordon push — route lookup + registry push80+# Push only — registry scopes
91-# Server auto-deploys when it receives the image
92 gordon auth token generate \81 gordon auth token generate \
93 --subject ci-bot \82 --subject ci-bot \
94- --scopes "push,pull,admin:routes:read" \83+ --scopes "push,pull" \
95 --expiry 90d84 --expiry 90d
96 85
97-# With explicit CLI-managed deploy (adds config:write for deploy control)86+# Push + apply/deploy — adds app mutation scopes
98 gordon auth token generate \87 gordon auth token generate \
99 --subject ci-bot \88 --subject ci-bot \
100- --scopes "push,pull,admin:routes:read,admin:config:write" \89+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
101 --expiry 90d90 --expiry 90d
102 91
103-# Scoped to a specific repository92+# Repository-scoped registry token for push only
104 gordon auth token generate \93 gordon auth token generate \
105 --subject ci-bot \94 --subject ci-bot \
106 --repo myapp \95 --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 \
113 --scopes "push,pull" \96 --scopes "push,pull" \
114 --expiry 90d97 --expiry 90d
115 ```98 ```
116 99
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.
118 101
119 ## Version Strategies102 ## Version Strategies
120 103
@@ -127,26 +110,24 @@ docker tag myapp gordon.example.com/myapp:latest
127 docker push gordon.example.com/myapp:latest110 docker push gordon.example.com/myapp:latest
128 ```111 ```
129 112
113+Reference it from the app manifest:
114+
130 ```toml115 ```toml
131-[routes]116+[[service]]
132-"app.example.com" = "myapp:latest"117+name = "web"
118+image = "gordon.example.com/myapp:latest"
133 ```119 ```
134 120
135 ### Semantic Versioning121 ### Semantic Versioning
136 122
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:
138 124
139 ```bash125 ```bash
140 docker tag myapp gordon.example.com/myapp:v2.1.0126 docker tag myapp gordon.example.com/myapp:v2.1.0
141 docker push gordon.example.com/myapp:v2.1.0127 docker push gordon.example.com/myapp:v2.1.0
142 ```128 ```
143 129
144-```toml130+To deploy a new version, update the tag in the app file, apply, and deploy.
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.
150 131
151 ### Git SHA Tags132 ### Git SHA Tags
152 133
@@ -158,20 +139,15 @@ docker tag myapp gordon.example.com/myapp:$VERSION
158 docker push gordon.example.com/myapp:$VERSION139 docker push gordon.example.com/myapp:$VERSION
159 ```140 ```
160 141
161-## Zero-Downtime Deployment142+## Updates
162-
163-Gordon performs zero-downtime deployments by default:
164 143
165-1. **New container starts** while the old one is still running144+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.
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
169 145
170 ```146 ```
171 Timeline ─────────────────────────────────────────>147 Timeline ─────────────────────────────────────────>
172 148
173 Old Container: [═══════════════════]149 Old Container: [═══════════════════]
174- ↓ stop150+ ↓ retire
175 New Container: [═════════════════════════>151 New Container: [═════════════════════════>
176 ↑ start ↑ traffic routed152 ↑ start ↑ traffic routed
177 ```153 ```
@@ -181,7 +157,5 @@ New Container: [══════════════════
181 - [GitHub Actions](./github-actions.md)157 - [GitHub Actions](./github-actions.md)
182 - [GitLab CI](./gitlab-ci.md)158 - [GitLab CI](./gitlab-ci.md)
183 - [Generic CI](./generic-ci.md)159 - [Generic CI](./generic-ci.md)
184-- [Rollback](./rollback.md)160+- [Apps CLI](../cli/apps.md)
185-- [Routes Configuration](../config/routes.md)
186 - [Authentication](../config/auth.md)161 - [Authentication](../config/auth.md)
187-- [Auto-Route](../config/auto-route.md)
M docs/deployment/rollback.md +28 -175
@@ -1,62 +1,28 @@
1 # Rollback1 # Rollback
2 2
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.
4 4
5-## Recommended: Use `gordon pin`5+## Roll Forward to a Previous Tag
6 6
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:
8 8
9 ```bash9 ```bash
10-gordon pin app.example.com10+# 1. Edit blog.toml: image = "gordon.mydomain.com/myapp:v2.0.0"
11-gordon pin app.example.com --tag v2.30.111+gordon apps apply --file blog.toml --remote prod
12-gordon pin list app.example.com12+gordon apps deploy blog --remote prod
13 ```13 ```
14 14
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.
16 16
17-## Rollback Strategies17+## Re-push the Previous Image as latest
18 18
19-### 1. Config-Based Rollback19+When the app file tracks `latest`, re-tag and re-push, then deploy (deploy re-resolves mutable tags):
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:
42 20
43 ```bash21 ```bash
44-docker pull registry.mydomain.com/myapp:v2.0.022+docker pull gordon.mydomain.com/myapp:v2.0.0
45-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest23+docker tag gordon.mydomain.com/myapp:v2.0.0 gordon.mydomain.com/myapp:latest
46-docker push registry.mydomain.com/myapp:latest24+docker push gordon.mydomain.com/myapp:latest
47-```25+gordon apps deploy blog --remote prod
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
60 ```26 ```
61 27
62 ## Version Management28 ## Version Management
@@ -67,169 +33,56 @@ Always push versioned tags alongside `latest`:
67 33
68 ```bash34 ```bash
69 VERSION=$(git describe --tags)35 VERSION=$(git describe --tags)
70-docker tag myapp registry.mydomain.com/myapp:$VERSION36+docker tag myapp gordon.mydomain.com/myapp:$VERSION
71-docker tag myapp registry.mydomain.com/myapp:latest37+docker tag myapp gordon.mydomain.com/myapp:latest
72-docker push registry.mydomain.com/myapp:$VERSION38+docker push gordon.mydomain.com/myapp:$VERSION
73-docker push registry.mydomain.com/myapp:latest39+docker push gordon.mydomain.com/myapp:latest
74 ```40 ```
75 41
76 ### Semantic Versioning42 ### Semantic Versioning
77 43
78-Use semantic versions for clear rollback targets:44+Use semantic versions for clear recovery targets:
79 45
80 ```46 ```
81 v2.1.0 ← Current (broken)47 v2.1.0 ← Current (broken)
82-v2.0.0 ← Rollback target48+v2.0.0 ← Recovery target
83 v1.9.0 ← Older stable49 v1.9.0 ← Older stable
84 ```50 ```
85 51
86 ### Git SHA Tags52 ### Git SHA Tags
87 53
88-Tag with commit SHA for precise rollbacks:54+Tag with commit SHA for precise recovery:
89 55
90 ```bash56 ```bash
91 # Deploy57 # Deploy
92 SHA=$(git rev-parse --short HEAD)58 SHA=$(git rev-parse --short HEAD)
93-docker push registry.mydomain.com/myapp:$SHA59+docker push gordon.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!"
206 ```60 ```
207 61
208-## Verifying Rollback62+## Verifying Recovery
209 63
210-After rollback:64+After re-deploying the previous version:
211 65
212 ```bash66 ```bash
213-# Check container is running correct version67+# Check the app's effective vs observed state
214-docker inspect gordon-app-mydomain-com | grep Image68+gordon apps status blog --remote prod
215 69
216 # Check application responds70 # Check application responds
217 curl -I https://app.mydomain.com71 curl -I https://app.mydomain.com
218 72
219 # Check logs73 # Check logs
220-gordon logs -f74+gordon apps logs blog --remote prod
221 ```75 ```
222 76
223 ## Best Practices77 ## Best Practices
224 78
225 1. **Always tag versions** - Don't rely solely on `latest`79 1. **Always tag versions** - Don't rely solely on `latest`
226-2. **Keep N previous versions** - Maintain rollback options80+2. **Keep N previous versions** - Maintain recovery options
227 3. **Test before deploy** - Reduce need for rollbacks81 3. **Test before deploy** - Reduce need for rollbacks
228 4. **Document known-good versions** - Track stable releases82 4. **Document known-good versions** - Track stable releases
229-5. **Automate rollback** - Reduce time to recovery
230 83
231 ## Related84 ## Related
232 85
233 - [Deployment Overview](./index.md)86 - [Deployment Overview](./index.md)
234 - [GitHub Actions](./github-actions.md)87 - [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
79 [entrypoints.edge]79 [entrypoints.edge]
80 address = ":443" # Public smart TCP edge (choose your bind/mapping)80 address = ":443" # Public smart TCP edge (choose your bind/mapping)
81 protocol = "smart_tcp"81 protocol = "smart_tcp"
82-
83-[routes]
84-"app.mydomain.com" = "myapp:latest" # Domain → Image mapping
85 ```82 ```
86 83
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+
87 ## 5. Set Up DNS (Including Wildcard)86 ## 5. Set Up DNS (Including Wildcard)
88 87
89 `gordon_domain` is the single domain used by both the registry and admin API.88 `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:
96 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |95 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
97 96
98 Why wildcard (`*`)?97 Why wildcard (`*`)?
99-- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.98+- It automatically covers app hosts 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.99+- You can add new app HTTP hosts without creating DNS records one by one.
101 100
102 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.101 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
103 102
@@ -129,10 +128,10 @@ sudo loginctl enable-linger $USER
129 128
130 ## 7. Generate a Deploy Token129 ## 7. Generate a Deploy Token
131 130
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):
133 132
134 ```bash133 ```bash
135-gordon auth token generate --subject deploy --scopes push,pull --expiry 0134+gordon auth token generate --subject deploy --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 90d
136 ```135 ```
137 136
138 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.137 `--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:
148 gordon remotes add prod https://gordon.mydomain.com --token <your-token>147 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
149 gordon remotes use prod148 gordon remotes use prod
150 149
151-# Recommended first-time setup150+# Build and push the image (OCI transfer only, never deploys)
152-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production151+gordon push myapp --build --remote prod
153-
154-# Then build, push, and deploy
155-gordon push myapp:latest --domain app.example.com --build --no-confirm
156 ```152 ```
157 153
158-What this command does:154+Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.
159 155
160-- `gordon bootstrap` creates or updates the route, applies requested attachments,156+```toml
161- and stores environment variables.157+name = "blog"
162-- This is the recommended path for first deploys because it does not require the158+
163- route to exist ahead of time.159+[[service]]
164-- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.160+name = "web"
161+image = "gordon.mydomain.com/myapp:latest"
162+
163+[[service.http]]
164+host = "app.mydomain.com"
165+port = 3000
166+```
165 167
166-If the route already exists and you only need to push a new image version, use:168+Apply the manifest, then deploy:
167 169
168 ```bash170 ```bash
169-gordon push myapp --build --no-confirm171+gordon apps apply --file blog.toml --remote prod
172+gordon apps deploy blog --remote prod
170 ```173 ```
171 174
172-`gordon push` still requires the route to already exist so it can resolve the175+What these commands do:
173-deploy target.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+```
174 186
175 Your app is now live at `https://app.mydomain.com`!187 Your app is now live at `https://app.mydomain.com`!
176 188
177 ## 9. Update Your App189 ## 9. Update Your App
178 190
179-Push a new image to deploy with zero downtime:191+Push a new image, update the manifest tag if needed, apply, deploy:
180 192
181 ```bash193 ```bash
182-# Make changes, then build + push + deploy194+# Make changes, then build + push
183-gordon push myapp --build --no-confirm195+gordon push myapp --build --remote prod
196+
197+# Deploy the new tag (re-resolves mutable tags)
198+gordon apps deploy blog --remote prod
184 ```199 ```
185 200
186-Gordon automatically: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.
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
191 202
192 ## Next Steps203 ## Next Steps
193 204
194 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers205 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers
195-- [Configuration Reference](./config/index.md) - All configuration options206+- [Configuration Reference](./config/index.md) - All installation configuration options
207+- [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands
196 - [Authentication](./config/auth.md) - Secure your registry208 - [Authentication](./config/auth.md) - Secure your registry
197-- [Environment Variables](./config/env.md) - Configure per-app settings209+- [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass
198 210
199 ## Related211 ## Related
200 212
M docs/index.md +10 -11
@@ -1,15 +1,16 @@
1 # Gordon Documentation1 # Gordon Documentation
2 2
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.
4 4
5 ## What is Gordon?5 ## What is Gordon?
6 6
7 Gordon runs on your VPS and provides:7 Gordon runs on your VPS and provides:
8 8
9 - **Private Container Registry** - Push images from your local machine or CI9 - **Private Container Registry** - Push images from your local machine or CI
10-- **HTTP Reverse Proxy** - Routes domains to containers automatically10+- **Declarative Apps** - One TOML file per app, explicit apply then deploy
11-- **Push-to-Deploy** - Containers deploy when you push new images11+- **HTTP Reverse Proxy** - Routes app hosts to containers
12-- **Zero-Downtime Updates** - New containers start before old ones stop12+- **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13+- **HTTP Zero-Downtime Updates** - Old containers serve until replacements pass readiness
13 - **Single Binary** - ~15MB RAM footprint14 - **Single Binary** - ~15MB RAM footprint
14 15
15 ## How It Works16 ## How It Works
@@ -31,7 +32,7 @@ Gordon runs on your VPS and provides:
31 32
32 1. Build your container locally where you have computing power33 1. Build your container locally where you have computing power
33 2. Push to your Gordon registry34 2. Push to your Gordon registry
34-3. Gordon automatically deploys and routes traffic to your container35+3. Apply the app manifest and deploy to activate it
35 36
36 ## Quick Navigation37 ## Quick Navigation
37 38
@@ -46,15 +47,13 @@ Gordon runs on your VPS and provides:
46 47
47 - [Configuration Overview](./config/index.md) - All configuration options48 - [Configuration Overview](./config/index.md) - All configuration options
48 - [Server Settings](./config/server.md) - Ports, domains, and runtime49 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49-- [Routes](./config/routes.md) - Domain to container mapping50+- [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
50 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints52 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
51 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows53 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
52-- [Secrets](./config/secrets.md) - Secure credential storage54+- [Secrets](./config/secrets.md) - Installation secrets and app secret values
53-- [Network Isolation](./config/network-isolation.md) - Per-app network isolation55+- [Network Isolation](./config/network-isolation.md) - Installation network policy
54-- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
55-- [Attachments](./config/attachments.md) - Service dependencies
56 - [Logging](./config/logging.md) - Log collection and rotation56 - [Logging](./config/logging.md) - Log collection and rotation
57-- [Environment Variables](./config/env.md) - Per-route environment configuration
58 57
59 ### CLI Reference58 ### CLI Reference
60 59
M docs/installation.md +40 -10
@@ -29,7 +29,25 @@ Detailed installation guide for production environments.
29 )29 )
30 ```30 ```
31 31
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`.
33 51
34 ### Manual Installation52 ### Manual Installation
35 53
@@ -136,6 +154,16 @@ insecure = true
136 EOF154 EOF
137 ```155 ```
138 156
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+
139 ## Firewall Configuration167 ## Firewall Configuration
140 168
141 Gordon needs ports accessible for the registry and the public edge entrypoint.169 Gordon needs ports accessible for the registry and the public edge entrypoint.
@@ -195,11 +223,10 @@ gordon_domain = "gordon.yourdomain.com"
195 [entrypoints.edge]223 [entrypoints.edge]
196 address = ":443"224 address = ":443"
197 protocol = "smart_tcp"225 protocol = "smart_tcp"
198-
199-[routes]
200-"app.yourdomain.com" = "myapp:latest"
201 ```226 ```
202 227
228+Application workloads live in standalone app files, not in `gordon.toml` (see [Getting Started](./getting-started.md#8-deploy-your-first-app)).
229+
203 See [Configuration Reference](./config/index.md) for all options.230 See [Configuration Reference](./config/index.md) for all options.
204 231
205 ## Systemd Service232 ## Systemd Service
@@ -310,17 +337,20 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
310 337
311 > **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.338 > **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.
312 339
313-## Installing a Specific Version or Pre-Release340+## Choosing an Install Channel
314 341
315 ```bash342 ```bash
316-# Install an exact version343+# Install an exact release
317-curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash344+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
318 348
319-# Install the latest pre-release instead of the latest stable release349+# Build the current next branch commit from source
320-curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash350+curl -fsSL https://gordon.bnema.dev/install | GORDON_CHANNEL=next sh
321 ```351 ```
322 352
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`.
324 354
325 ## Verify Installation355 ## Verify Installation
326 356
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.
4 4
5 ## Container Labels5 ## Container Labels
6 6
7-Gordon adds these labels to managed containers:7+Gordon stamps these ownership labels on every container it creates:
8 8
9 | Label | Value | Description |9 | Label | Value | Description |
10 |-------|-------|-------------|10 |-------|-------|-------------|
11 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |11 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |
12-| `gordon.domain` | Domain name | Domain this container serves |12+| `gordon.app` | App name | App this container serves |
13-| `gordon.image` | Image:tag | Original image from configuration |13+| `gordon.app.service` | Service name | Service this container runs |
14-| `gordon.route` | Domain name | Route this container handles |14+| `gordon.app.revision` | Revision | Active revision that created it |
15 | `gordon.created` | Timestamp | When Gordon created the container |15 | `gordon.created` | Timestamp | When Gordon created the container |
16 16
17-### Propagated Image Labels17+Queries by logical identity use labels, never name parsing. Resources without these labels (old or foreign containers) are preserved, never adopted or deleted.
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.
27 18
28-### Attachment Labels19+### Legacy Labels
29 20
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:
31 22
32 | Label | Value | Description |23 | Label | Value | Description |
33 |-------|-------|-------------|24 |-------|-------|-------------|
34-| `gordon.attachment` | `"true"` | Container is an attachment service |25+| `gordon.domain` | Domain name | Pre-v2.50 domain this container served |
35-| `gordon.attached-to` | Domain/group | Route or network group this serves |26+| `gordon.image` | Image:tag | Original image from configuration |
36-27+| `gordon.route` | Domain name | Pre-v2.50 route this container handled |
37-### Backup Labels
38 28
39-Labels used by the backup subsystem:29+### Backup Metadata
40 30
41-| Label | Value | Description |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.
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 |
48 32
49 ## Image Labels33 ## Image Labels
50 34
51-Labels you can set in your Dockerfile: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.
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
94 36
95 ## Container Naming37 ## Container Naming
96 38
97-Gordon names containers:39+Gordon names containers `gordon-<app>--<service>--<instance>` where `<instance>` is the creating operation ID. Retiring containers keep their instance names until removal.
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) |
104 40
105 ## Inspecting Labels41 ## Inspecting Labels
106 42
107 View labels on a container:43 View labels on a container:
108 44
109 ```bash45 ```bash
110-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | jq46+docker inspect <container> --format '{{json .Config.Labels}}' | jq
111 ```47 ```
112 48
113 Example output:49 Example output:
114 50
115 ```json51 ```json
116 {52 {
117- "gordon.created": "2024-01-15T10:30:00Z",
118- "gordon.domain": "app.mydomain.com",
119- "gordon.image": "myapp:latest",
120 "gordon.managed": "true",53 "gordon.managed": "true",
121- "gordon.proxy.port": "3000",54+ "gordon.app": "blog",
122- "gordon.route": "app.mydomain.com"55+ "gordon.app.service": "web",
56+ "gordon.app.revision": "rev-3",
57+ "gordon.created": "2024-01-15T10:30:00Z"
123 }58 }
124 ```59 ```
125 60
@@ -131,75 +66,12 @@ Find Gordon-managed containers:
131 # All Gordon containers66 # All Gordon containers
132 docker ps -f "label=gordon.managed=true"67 docker ps -f "label=gordon.managed=true"
133 68
134-# Containers for specific domain69+# Containers for a specific app
135-docker ps -f "label=gordon.domain=app.mydomain.com"70+docker ps -f "label=gordon.app=blog"
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}}'
169 ```71 ```
170 72
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-
201 ## Related73 ## Related
202 74
203 - [Configuration Overview](../config/index.md)75 - [Configuration Overview](../config/index.md)
204-- [Attachments](../config/attachments.md)76+- [App Manifest](../config/apps.md)
205 - [Concepts](../concepts.md)77 - [Concepts](../concepts.md)
M docs/reference/troubleshooting.md +16 -30
@@ -118,17 +118,13 @@ No restart needed — Podman reads this on each pull/push.
118 118
119 ### "unknown: image not found"119 ### "unknown: image not found"
120 120
121-**Cause:** Image was pushed but no route configured.121+**Cause:** The app manifest references an image that is unavailable to the runtime.
122 122
123-**Solution:** Add route to config: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:
124-```toml
125-[routes]
126-"app.mydomain.com" = "myapp:latest"
127-```
128 124
129-Then reload:
130 ```bash125 ```bash
131-gordon reload126+gordon apps apply --file app.toml
127+gordon apps deploy app
132 ```128 ```
133 129
134 ## Deployment Issues130 ## Deployment Issues
@@ -161,9 +157,9 @@ gordon reload
161 157
162 **Solutions:**158 **Solutions:**
163 159
164-1. Check container logs:160+1. Check workload logs:
165 ```bash161 ```bash
166- docker logs gordon-app-mydomain-com162+ gordon apps logs blog --service web
167 ```163 ```
168 164
169 2. Check Gordon logs:165 2. Check Gordon logs:
@@ -234,18 +230,14 @@ gordon reload
234 230
235 **Solutions:**231 **Solutions:**
236 232
237-1. Check attachments are configured:233+1. Check that both services declare the same shared network in the app manifest.
238- ```toml
239- [attachments]
240- "app.mydomain.com" = ["postgres:latest"]
241- ```
242 234
243-2. Check containers are in same network:235+2. Check containers are attached to that network:
244 ```bash236 ```bash
245- docker network inspect gordon-app-mydomain-com237+ docker network inspect NETWORK_NAME
246 ```238 ```
247 239
248-3. Use correct hostname (image name before colon):240+3. Use the service name as the internal hostname:
249 ```javascript241 ```javascript
250 // Correct242 // Correct
251 connect("postgresql://postgres:5432/mydb")243 connect("postgresql://postgres:5432/mydb")
@@ -369,15 +361,9 @@ enabled = true
369 path = "~/.gordon/logs/gordon.log"361 path = "~/.gordon/logs/gordon.log"
370 ```362 ```
371 363
372-### Container logs missing364+### Workload logs unavailable
373-
374-**Cause:** Container log collection disabled.
375 365
376-**Solution:**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.
377-```toml
378-[logging.container_logs]
379-enabled = true # default: true
380-```
381 367
382 ## Diagnostic Commands368 ## Diagnostic Commands
383 369
@@ -398,11 +384,11 @@ docker network ls | grep gordon
398 # List volumes384 # List volumes
399 docker volume ls | grep gordon385 docker volume ls | grep gordon
400 386
401-# Check container logs387+# Check workload logs through Gordon
402-docker logs gordon-app-mydomain-com388+gordon apps logs blog --service web
403 389
404-# Inspect container390+# Inspect runtime containers when diagnosing locally
405-docker inspect gordon-app-mydomain-com391+docker ps -f "label=gordon.app=blog"
406 392
407 # Check connectivity393 # Check connectivity
408 curl -v http://localhost:5000/v2/394 curl -v http://localhost:5000/v2/
M docs/upgrading.md +26 -4
@@ -2,6 +2,28 @@
2 2
3 This guide covers breaking changes and migration steps between major versions.3 This guide covers breaking changes and migration steps between major versions.
4 4
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+
5 ## Route-Domain Validation27 ## Route-Domain Validation
6 28
7 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.29 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"
114 gordon_domain = "gordon.example.com"136 gordon_domain = "gordon.example.com"
115 ```137 ```
116 138
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`.
118 140
119 ### Staged Registry Host Rename141 ### Staged Registry Host Rename
120 142
@@ -214,7 +236,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
214 - `gordon auth show-token` prints the stored token for a remote236 - `gordon auth show-token` prints the stored token for a remote
215 - `gordon auth logout` removes the stored token locally237 - `gordon auth logout` removes the stored token locally
216 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls238 - 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 operations239+- Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations
218 240
219 **Migration steps:**241 **Migration steps:**
220 242
@@ -294,8 +316,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi
294 # Read-only monitoring316 # Read-only monitoring
295 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d317 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
296 318
297-# CI deploy with route read + config write319+# CI deploy with app read + write
298-gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0320+gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0
299 ```321 ```
300 322
301 See [Token Scopes](./config/auth.md#token-scopes) for the full list.323 See [Token Scopes](./config/auth.md#token-scopes) for the full list.
M go.mod +2 -1
@@ -32,6 +32,7 @@ require (
32 github.com/spf13/cobra v1.10.232 github.com/spf13/cobra v1.10.2
33 github.com/spf13/viper v1.21.033 github.com/spf13/viper v1.21.0
34 github.com/stretchr/testify v1.12.134 github.com/stretchr/testify v1.12.1
35+ go.etcd.io/bbolt v1.5.0
35 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.036 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
36 go.opentelemetry.io/otel v1.46.037 go.opentelemetry.io/otel v1.46.0
37 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.038 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0
@@ -43,7 +44,6 @@ require (
43 go.opentelemetry.io/otel/sdk/metric v1.46.044 go.opentelemetry.io/otel/sdk/metric v1.46.0
44 go.opentelemetry.io/otel/trace v1.46.045 go.opentelemetry.io/otel/trace v1.46.0
45 go.uber.org/mock v0.6.0 // direct46 go.uber.org/mock v0.6.0 // direct
46- golang.org/x/mod v0.40.0
47 golang.org/x/sync v0.22.047 golang.org/x/sync v0.22.0
48 golang.org/x/sys v0.47.048 golang.org/x/sys v0.47.0
49 golang.org/x/time v0.15.049 golang.org/x/time v0.15.0
@@ -113,6 +113,7 @@ require (
113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect
114 go.yaml.in/yaml/v3 v3.0.5 // indirect114 go.yaml.in/yaml/v3 v3.0.5 // indirect
115 golang.org/x/crypto v0.55.0 // indirect115 golang.org/x/crypto v0.55.0 // indirect
116+ golang.org/x/mod v0.40.0 // indirect
116 golang.org/x/net v0.58.0 // indirect117 golang.org/x/net v0.58.0 // indirect
117 golang.org/x/text v0.41.0 // indirect118 golang.org/x/text v0.41.0 // indirect
118 golang.org/x/tools v0.49.0 // indirect119 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"
70 security_profile = "compat"70 security_profile = "compat"
71 71
72 [network_isolation]72 [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.
73 enabled = true76 enabled = true
74 network_prefix = "gordon"77 network_prefix = "gordon"
75 # Set true to create Docker internal networks without direct external egress.78 # Set true to create Docker internal networks without direct external egress.
@@ -81,10 +84,10 @@ prefix = "gordon"
81 preserve = true84 preserve = true
82 85
83 [images]86 [images]
84-# Explicit external registries must be listed here. Gordon's own registry is87+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed.
85-# always allowed; localhost/private/link-local registries are always rejected.88+# Add other registries as exact hostname+port entries; this does not configure credentials.
86 allowed_registries = []89 allowed_registries = []
87-# Require @sha256:<digest> for allowlisted external registries.90+# Require @sha256:<64 hex chars> for images from every registry, including Gordon.
88 require_digest = false91 require_digest = false
89 92
90 [images.prune]93 [images.prune]
@@ -92,9 +95,6 @@ enabled = false
92 schedule = "daily"95 schedule = "daily"
93 keep_last = 396 keep_last = 3
94 97
95-[auto_route]
96-enabled = false
97-
98 [logging]98 [logging]
99 level = "info"99 level = "info"
100 format = "console"100 format = "console"
@@ -106,13 +106,6 @@ enabled = false
106 # max_backups = 3106 # max_backups = 3
107 # max_age = 28107 # max_age = 28
108 108
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-
116 [logging.access_log]109 [logging.access_log]
117 enabled = false110 enabled = false
118 format = "json"111 format = "json"
@@ -173,9 +166,27 @@ keep = 14
173 166
174 [external_routes]167 [external_routes]
175 # "status.example.com" = "127.0.0.1:9000"168 # "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
176 184
177 # Gordon-managed standalone L4 services. Use service:<name>:<port> from185 # 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.)
179 # [[services]]190 # [[services]]
180 # name = "rust"191 # name = "rust"
181 # image = "registry.example.com:5000/rust:latest"192 # image = "registry.example.com:5000/rust:latest"
@@ -213,9 +224,6 @@ keep = 14
213 # name = "rust-rcon"224 # name = "rust-rcon"
214 # entrypoint = "rcon"225 # entrypoint = "rcon"
215 # service = "service:rust:rcon"226 # service = "service:rust:rcon"
216-227+#
217-[network_groups]228+# REMOVED application sections (see note under [external_routes] above):
218-# "backend" = ["app.example.com", "api.example.com"]229+# [routes], [network_groups], [attachments] were deleted with the cutover.
219-
220-[attachments]
221-# "app.example.com" = ["postgres:18"]
M install.sh +301 -18
@@ -2,12 +2,55 @@
2 set -e2 set -e
3 3
4 # Gordon installer script4 # Gordon installer script
5-# Usage: curl -fsSL https://gordon.bnema.dev/install | bash5+# Usage: curl -fsSL https://gordon.bnema.dev/install | sh
6-# Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash6+# 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
7 8
8 REPO="bnema/gordon"9 REPO="bnema/gordon"
9-INSTALL_DIR="/usr/local/bin"
10 VERSION="${GORDON_VERSION:-latest}"10 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
11 54
12 echo "Installing Gordon..."55 echo "Installing Gordon..."
13 56
@@ -43,6 +86,257 @@ esac
43 86
44 echo "Detected: ${OS}/${ARCH}"87 echo "Detected: ${OS}/${ARCH}"
45 88
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+
46 # Construct download URL340 # Construct download URL
47 TARBALL="gordon_${OS}_${ARCH}.tar.gz"341 TARBALL="gordon_${OS}_${ARCH}.tar.gz"
48 342
@@ -101,6 +395,8 @@ fi
101 395
102 # Verify checksum396 # Verify checksum
103 echo "Verifying checksum..."397 echo "Verifying checksum..."
398+# The sed expression is intentionally literal; shell expansion would corrupt it.
399+# shellcheck disable=SC2016
104 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')400 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)401 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
106 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')402 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
@@ -147,19 +443,6 @@ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
147 fi443 fi
148 444
149 echo "Installing to ${INSTALL_DIR}..."445 echo "Installing to ${INSTALL_DIR}..."
150-if [ -w "$INSTALL_DIR" ]; then446+install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
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
156 447
157-# Verify installation448+post_install
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
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
2 2
3 import "time"3 import "time"
4 4
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.
6 type BackupJob struct {7 type BackupJob struct {
7 ID string `json:"id"`8 ID string `json:"id"`
8- Domain string `json:"domain"`9+ App string `json:"app"`
9- DBName string `json:"db_name"`10+ Service string `json:"service,omitempty"`
11+ Database string `json:"database,omitempty"`
10 Schedule string `json:"schedule,omitempty"`12 Schedule string `json:"schedule,omitempty"`
11 Type string `json:"type"`13 Type string `json:"type"`
12 Status string `json:"status"`14 Status string `json:"status"`
@@ -21,9 +23,12 @@ type BackupsResponse struct {
21 Backups []BackupJob `json:"backups"`23 Backups []BackupJob `json:"backups"`
22 }24 }
23 25
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.
25 type BackupRunRequest struct {29 type BackupRunRequest struct {
26- DB string `json:"db,omitempty"`30+ Service string `json:"service"`
31+ Database string `json:"database,omitempty"`
27 }32 }
28 33
29 // BackupRunResponse is returned after triggering a backup.34 // BackupRunResponse is returned after triggering a backup.
@@ -32,38 +37,23 @@ type BackupRunResponse struct {
32 Backup *BackupJob `json:"backup,omitempty"`37 Backup *BackupJob `json:"backup,omitempty"`
33 }38 }
34 39
35-// DatabaseInfo represents a detected database attachment.40+// VolumeBackupJob represents volume backup metadata in admin API
36-type DatabaseInfo struct {41+// responses. App, service, and the declared volume name are the identity.
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.
52 type VolumeBackupJob struct {42 type VolumeBackupJob struct {
53- ID string `json:"id"`43+ ID string `json:"id"`
54- Domain string `json:"domain"`44+ App string `json:"app"`
55- ContainerName string `json:"container_name,omitempty"`45+ Service string `json:"service,omitempty"`
56- ContainerID string `json:"container_id,omitempty"`46+ VolumeName string `json:"volume"`
57- VolumeName string `json:"volume_name"`47+ RuntimeVolumeName string `json:"runtime_volume,omitempty"`
58- MountPath string `json:"mount_path,omitempty"`48+ MountPath string `json:"mount_path,omitempty"`
59- Compression string `json:"compression,omitempty"`49+ Compression string `json:"compression,omitempty"`
60- Type string `json:"type"`50+ Type string `json:"type"`
61- Status string `json:"status"`51+ Status string `json:"status"`
62- StartedAt *time.Time `json:"started_at,omitempty"`52+ StartedAt *time.Time `json:"started_at,omitempty"`
63- CompletedAt *time.Time `json:"completed_at,omitempty"`53+ CompletedAt *time.Time `json:"completed_at,omitempty"`
64- SizeBytes int64 `json:"size_bytes"`54+ SizeBytes int64 `json:"size_bytes"`
65- ArtifactRef string `json:"artifact_ref,omitempty"`55+ ArtifactRef string `json:"artifact_ref,omitempty"`
66- Error string `json:"error,omitempty"`56+ Error string `json:"error,omitempty"`
67 }57 }
68 58
69 // VolumeBackupsResponse is returned by volume backup listing endpoints.59 // VolumeBackupsResponse is returned by volume backup listing endpoints.
@@ -71,12 +61,13 @@ type VolumeBackupsResponse struct {
71 Backups []VolumeBackupJob `json:"backups"`61 Backups []VolumeBackupJob `json:"backups"`
72 }62 }
73 63
74-// VolumeBackupRunRequest triggers volume backups.64+// VolumeBackupRunRequest triggers one declared volume backup.
75 type VolumeBackupRunRequest struct {65 type VolumeBackupRunRequest struct {
76- Volume string `json:"volume,omitempty"`66+ Service string `json:"service"`
67+ Volume string `json:"volume,omitempty"`
77 }68 }
78 69
79-// VolumeBackupRunResponse is returned after triggering volume backups.70+// VolumeBackupRunResponse is returned after triggering a volume backup.
80 type VolumeBackupRunResponse struct {71 type VolumeBackupRunResponse struct {
81 Status string `json:"status"`72 Status string `json:"status"`
82 Backups []VolumeBackupJob `json:"backups,omitempty"`73 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 @@
1 package dto1 package dto
2 2
3 // ConfigResponse represents server configuration.3 // ConfigResponse represents server configuration.
4+// ConfigResponse represents installation configuration. App routes live
5+// under apps show, never here.
4 type ConfigResponse struct {6 type ConfigResponse struct {
5 Server ServerConfig `json:"server"`7 Server ServerConfig `json:"server"`
6- AutoRoute AutoRouteConfig `json:"auto_route"`
7 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`8 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
8 Volumes VolumesConfig `json:"volumes"`9 Volumes VolumesConfig `json:"volumes"`
9- Routes []Route `json:"routes"`
10 ExternalRoutes []ExternalRoute `json:"external_routes"`10 ExternalRoutes []ExternalRoute `json:"external_routes"`
11 }11 }
12 12
@@ -18,11 +18,6 @@ type ServerConfig struct {
18 DataDir string `json:"data_dir,omitempty"`18 DataDir string `json:"data_dir,omitempty"`
19 }19 }
20 20
21-// AutoRouteConfig represents auto-route config details.
22-type AutoRouteConfig struct {
23- Enabled bool `json:"enabled"`
24-}
25-
26 // NetworkIsolationConfig represents network isolation settings.21 // NetworkIsolationConfig represents network isolation settings.
27 type NetworkIsolationConfig struct {22 type NetworkIsolationConfig struct {
28 Enabled bool `json:"enabled"`23 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 {
17 Images []Image `json:"images"`17 Images []Image `json:"images"`
18 }18 }
19 19
20-// ImagePruneRequest triggers image pruning.20+// ImagePruneRequest triggers image pruning. DryRun plans the full
21+// operation and deletes nothing.
21 type ImagePruneRequest struct {22 type ImagePruneRequest struct {
22 KeepLast *int `json:"keep_last,omitempty"`23 KeepLast *int `json:"keep_last,omitempty"`
23 PruneDangling *bool `json:"prune_dangling,omitempty"`24 PruneDangling *bool `json:"prune_dangling,omitempty"`
24 PruneRegistry *bool `json:"prune_registry,omitempty"`25 PruneRegistry *bool `json:"prune_registry,omitempty"`
26+ DryRun *bool `json:"dry_run,omitempty"`
25 }27 }
26 28
27 // RuntimePruneResult represents runtime prune results.29 // RuntimePruneResult represents runtime prune results.
@@ -37,8 +39,10 @@ type RegistryPruneResult struct {
37 SpaceReclaimed int64 `json:"space_reclaimed"`39 SpaceReclaimed int64 `json:"space_reclaimed"`
38 }40 }
39 41
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.
41 type ImagePruneResponse struct {44 type ImagePruneResponse struct {
42 Runtime RuntimePruneResult `json:"runtime"`45 Runtime RuntimePruneResult `json:"runtime"`
43 Registry RegistryPruneResult `json:"registry"`46 Registry RegistryPruneResult `json:"registry"`
47+ Plan PruneSummary `json:"plan"`
44 }48 }
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 @@
1 package dto1 package dto
2 2
3-// AttachmentSecretsResponse represents secrets for an attachment container.
4-type AttachmentSecretsResponse struct {
5- Service string `json:"service"`
6- Keys []string `json:"keys"`
7-}
8-
9 // SecretsListResponse represents a list of secret keys for a domain.3 // SecretsListResponse represents a list of secret keys for a domain.
10 type SecretsListResponse struct {4 type SecretsListResponse struct {
11- Domain string `json:"domain"`5+ Domain string `json:"domain"`
12- Keys []string `json:"keys"`6+ Keys []string `json:"keys"`
13- Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"`
14 }7 }
15 8
16 // SecretsStatusResponse represents a secrets write response.9 // SecretsStatusResponse represents a secrets write response.
M internal/adapters/dto/admin_status.go +1 -2
@@ -2,11 +2,10 @@ package dto
2 2
3 // StatusResponse represents admin status information.3 // StatusResponse represents admin status information.
4 type StatusResponse struct {4 type StatusResponse struct {
5- Routes int `json:"routes"`5+ Apps int `json:"apps"`
6 RegistryDomain string `json:"registry_domain"`6 RegistryDomain string `json:"registry_domain"`
7 RegistryPort int `json:"registry_port"`7 RegistryPort int `json:"registry_port"`
8 ServerPort int `json:"server_port"`8 ServerPort int `json:"server_port"`
9- AutoRoute bool `json:"auto_route"`
10 NetworkIsolation bool `json:"network_isolation"`9 NetworkIsolation bool `json:"network_isolation"`
11 ContainerStatuses map[string]string `json:"container_status"`10 ContainerStatuses map[string]string `json:"container_status"`
12 }11 }
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 {
20 }20 }
21 21
22 // VolumePruneResponse contains the results of a volume prune operation.22 // VolumePruneResponse contains the results of a volume prune operation.
23+// Plan is the same shape for dry runs and executions.
23 type VolumePruneResponse struct {24 type VolumePruneResponse struct {
24- VolumesRemoved int `json:"volumes_removed"`25+ VolumesRemoved int `json:"volumes_removed"`
25- SpaceReclaimed int64 `json:"space_reclaimed"`26+ SpaceReclaimed int64 `json:"space_reclaimed"`
26- Volumes []Volume `json:"volumes,omitempty"`27+ Volumes []Volume `json:"volumes,omitempty"`
28+ Plan PruneSummary `json:"plan"`
27 }29 }
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 {
60 // newTokenGenerateCmd creates the token generate command.60 // newTokenGenerateCmd creates the token generate command.
61 func newTokenGenerateCmd() *cobra.Command {61 func newTokenGenerateCmd() *cobra.Command {
62 var (62 var (
63- subject string63+ subject string
64- scopes string64+ scopes string
65- expiry string65+ expiry string
66- configPath string66+ cliConfigPath string
67- repo string67+ repo string
68+ rawOut bool
69+ jsonOut bool
68 )70 )
69 71
70 cmd := &cobra.Command{72 cmd := &cobra.Command{
@@ -84,12 +86,13 @@ Registry scopes (for Docker push/pull):
84 Admin scopes (for remote CLI access):86 Admin scopes (for remote CLI access):
85 Format: admin:<resource>:<actions>87 Format: admin:<resource>:<actions>
86 88
87- Resources: routes, secrets, config, status, logs, volumes, * (all)89+ Resources: apps, secrets, config, status, logs, volumes, * (all)
88 Actions: read, write, * (all)90 Actions: read, write, * (all)
89 91
90 Examples:92 Examples:
91 admin:*:* Full admin access (recommended for CLI)93 admin:*:* Full admin access (recommended for CLI)
92- admin:routes:read Read-only routes access94+ admin:apps:read Read-only apps access
95+ admin:apps:write App mutations (apply, deploy, lifecycle)
93 admin:status:read Read-only status access96 admin:status:read Read-only status access
94 admin:logs:read Read-only log access97 admin:logs:read Read-only log access
95 admin:volumes:read Read-only volume access98 admin:volumes:read Read-only volume access
@@ -102,8 +105,8 @@ Repository scoping:
102 Combined examples:105 Combined examples:
103 --scopes "push,pull" All repos, registry only (default)106 --scopes "push,pull" All repos, registry only (default)
104 --scopes "push" --repo myapp Push to myapp only107 --scopes "push" --repo myapp Push to myapp only
105- --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)108+ --scopes "push,pull,admin:apps:read" Minimum CI scope (all repos)
106- --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)109+ --scopes "push,admin:apps:read" --repo app Minimum CI scope (specific repo)
107 110
108 EXPIRY:111 EXPIRY:
109 112
@@ -114,15 +117,18 @@ Supports human-friendly durations:
114 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d117 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
115 Use --expiry=0 for a token that never expires (useful for CI).`,118 Use --expiry=0 for a token that never expires (useful for CI).`,
116 RunE: func(cmd *cobra.Command, args []string) error {119 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)
118 },121 },
119 }122 }
120 123
121 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")124 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
122 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")125 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
123 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")126 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")
125 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")128 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")
126 132
127 _ = cmd.MarkFlagRequired("subject")133 _ = cmd.MarkFlagRequired("subject")
128 134
@@ -132,8 +138,8 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
132 // newTokenListCmd creates the token list command.138 // newTokenListCmd creates the token list command.
133 func newTokenListCmd() *cobra.Command {139 func newTokenListCmd() *cobra.Command {
134 var (140 var (
135- configPath string141+ cliConfigPath string
136- jsonOut bool142+ jsonOut bool
137 )143 )
138 144
139 cmd := &cobra.Command{145 cmd := &cobra.Command{
@@ -141,11 +147,11 @@ func newTokenListCmd() *cobra.Command {
141 Short: "List all authentication tokens",147 Short: "List all authentication tokens",
142 Long: `List all stored authentication tokens with their subjects and expiry information.`,148 Long: `List all stored authentication tokens with their subjects and expiry information.`,
143 RunE: func(cmd *cobra.Command, args []string) error {149 RunE: func(cmd *cobra.Command, args []string) error {
144- return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)150+ return runTokenList(cliConfigPath, cmd.OutOrStdout(), jsonOut)
145 },151 },
146 }152 }
147 153
148- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")154+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
149 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")155 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
150 156
151 return cmd157 return cmd
@@ -154,8 +160,8 @@ func newTokenListCmd() *cobra.Command {
154 // newTokenRevokeCmd creates the token revoke command.160 // newTokenRevokeCmd creates the token revoke command.
155 func newTokenRevokeCmd() *cobra.Command {161 func newTokenRevokeCmd() *cobra.Command {
156 var (162 var (
157- configPath string163+ cliConfigPath string
158- all bool164+ all bool
159 )165 )
160 166
161 cmd := &cobra.Command{167 cmd := &cobra.Command{
@@ -169,16 +175,16 @@ Examples:
169 Args: cobra.MaximumNArgs(1),175 Args: cobra.MaximumNArgs(1),
170 RunE: func(cmd *cobra.Command, args []string) error {176 RunE: func(cmd *cobra.Command, args []string) error {
171 if all {177 if all {
172- return runTokenRevokeAll(configPath)178+ return runTokenRevokeAll(cliConfigPath)
173 }179 }
174 if len(args) == 0 {180 if len(args) == 0 {
175 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")181 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
176 }182 }
177- return runTokenRevoke(args[0], configPath)183+ return runTokenRevoke(args[0], cliConfigPath)
178 },184 },
179 }185 }
180 186
181- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")187+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
182 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")188 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
183 189
184 return cmd190 return cmd
@@ -188,18 +194,21 @@ Examples:
188 func newAuthInternalCmd() *cobra.Command {194 func newAuthInternalCmd() *cobra.Command {
189 return &cobra.Command{195 return &cobra.Command{
190 Use: "internal",196 Use: "internal",
191- Short: "Show internal registry credentials for manual recovery",197+ Short: "Show server-local registry recovery credentials",
192- Long: `Display the auto-generated internal registry 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.
193 203
194-These credentials are used by Gordon for pulling images from the local registry204+For local recovery only:
195-(localhost:5000) during internal deploys. You can use them for manual recovery:
196 205
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
198 208
199-Note: Credentials are regenerated each time Gordon starts, and are only209+Credentials are regenerated whenever the Gordon daemon starts.`,
200-available while Gordon is running.`,
201 RunE: func(cmd *cobra.Command, args []string) error {210 RunE: func(cmd *cobra.Command, args []string) error {
202- return runShowInternalAuth()211+ return runShowInternalAuth(cmd.OutOrStdout())
203 },212 },
204 }213 }
205 }214 }
@@ -402,21 +411,31 @@ func runAuthLogout(out io.Writer) error {
402 }411 }
403 412
404 // runShowInternalAuth displays the internal registry credentials.413 // 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+
406 creds, err := app.GetInternalCredentials()421 creds, err := app.GetInternalCredentials()
407 if err != nil {422 if err != nil {
408 return err423 return err
409 }424 }
410 425
411- fmt.Println("Internal Registry Credentials")426+ if err := cliWriteLine(out, "Internal Registry Credentials"); err != nil {
412- fmt.Println("==============================")427+ return err
413- fmt.Printf("Username: %s\n", creds.Username)428+ }
414- fmt.Printf("Password: %s\n", creds.Password)429+ if err := cliWriteLine(out, "=============================="); err != nil {
415- fmt.Println()430+ return err
416- fmt.Println("Usage:")431+ }
417- fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)432+ if err := cliWritef(out, "Username: %s\n", creds.Username); err != nil {
418-433+ return err
419- return nil434+ }
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.")
420 }439 }
421 440
422 // runTokenGenerate generates a new authentication token.441 // runTokenGenerate generates a new authentication token.
@@ -463,8 +482,8 @@ func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
463 return scopes, nil482 return scopes, nil
464 }483 }
465 484
466-func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {485+func runTokenGenerate(subject, scopesStr, expiryStr, cliConfigPath, repo string, out io.Writer, rawOut, jsonOut bool) error {
467- cfg, err := loadAuthConfig(configPath)486+ cfg, err := loadAuthConfig(cliConfigPath)
468 if err != nil {487 if err != nil {
469 return err488 return err
470 }489 }
@@ -497,26 +516,34 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
497 return fmt.Errorf("failed to generate token: %w", err)516 return fmt.Errorf("failed to generate token: %w", err)
498 }517 }
499 518
500- fmt.Println("Token generated successfully!")519+ if rawOut {
501- fmt.Printf("Subject: %s\n", subject)520+ return cliWriteLine(out, token)
502- fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))521+ }
503- if expiry == 0 {522+ if jsonOut {
504- fmt.Println("Expiry: never")523+ return writeJSON(out, map[string]any{
505- } else {524+ "subject": subject,
506- fmt.Printf("Expiry: %s\n", expiry)525+ "scopes": scopes,
526+ "expiry": expiry.String(),
527+ "token": token,
528+ })
507 }529 }
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)
513 530
514- return nil531+ 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)
515 }542 }
516 543
517 // runTokenList lists all stored tokens.544 // runTokenList lists all stored tokens.
518-func runTokenList(configPath string, out io.Writer, jsonOut bool) error {545+func runTokenList(cliConfigPath string, out io.Writer, jsonOut bool) error {
519- cfg, err := loadAuthConfig(configPath)546+ cfg, err := loadAuthConfig(cliConfigPath)
520 if err != nil {547 if err != nil {
521 return err548 return err
522 }549 }
@@ -579,8 +606,8 @@ func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
579 }606 }
580 607
581 // runTokenRevoke revokes a token by ID.608 // runTokenRevoke revokes a token by ID.
582-func runTokenRevoke(tokenID, configPath string) error {609+func runTokenRevoke(tokenID, cliConfigPath string) error {
583- cfg, err := loadAuthConfig(configPath)610+ cfg, err := loadAuthConfig(cliConfigPath)
584 if err != nil {611 if err != nil {
585 return err612 return err
586 }613 }
@@ -601,8 +628,8 @@ func runTokenRevoke(tokenID, configPath string) error {
601 }628 }
602 629
603 // runTokenRevokeAll revokes all tokens.630 // runTokenRevokeAll revokes all tokens.
604-func runTokenRevokeAll(configPath string) error {631+func runTokenRevokeAll(cliConfigPath string) error {
605- cfg, err := loadAuthConfig(configPath)632+ cfg, err := loadAuthConfig(cliConfigPath)
606 if err != nil {633 if err != nil {
607 return err634 return err
608 }635 }
@@ -635,14 +662,14 @@ type cliConfig struct {
635 }662 }
636 663
637 // loadAuthConfig loads the configuration needed for auth CLI commands.664 // loadAuthConfig loads the configuration needed for auth CLI commands.
638-func loadAuthConfig(configPath string) (*cliConfig, error) {665+func loadAuthConfig(cliConfigPath string) (*cliConfig, error) {
639 v := viper.New()666 v := viper.New()
640 667
641 // Set defaults668 // Set defaults
642 v.SetDefault("server.data_dir", app.DefaultDataDir())669 v.SetDefault("server.data_dir", app.DefaultDataDir())
643 v.SetDefault("auth.secrets_backend", "unsafe")670 v.SetDefault("auth.secrets_backend", "unsafe")
644 671
645- app.ConfigureViper(v, configPath)672+ app.ConfigureViper(v, cliConfigPath)
646 673
647 if err := v.ReadInConfig(); err != nil {674 if err := v.ReadInConfig(); err != nil {
648 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {675 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) {
26 }26 }
27 27
28 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {28 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")
30 require.NoError(t, err)30 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)
32 }32 }
33 33
34 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {34 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 (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7- "os"
8 "text/tabwriter"7 "text/tabwriter"
9 "time"8 "time"
10 9
@@ -14,54 +13,35 @@ import (
14 "github.com/bnema/gordon/pkg/bytesize"13 "github.com/bnema/gordon/pkg/bytesize"
15 )14 )
16 15
17-var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {16+// newBackupCmd creates the backup command group. Backups are identified by
18- if domainName != "" {17+// app, service, and the declared database or volume: a domain is never an
19- return resolveControlPlaneForRouteDomain(ctx, domainName)18+// identity.
20- }
21- return resolveControlPlane(configPath)
22-}
23-
24-// newBackupCmd creates the backup command group.
25 func newBackupCmd() *cobra.Command {19 func newBackupCmd() *cobra.Command {
26 cmd := &cobra.Command{20 cmd := &cobra.Command{
27 Use: "backups",21 Use: "backups",
28 Aliases: []string{"backup"},22 Aliases: []string{"backup"},
29- Short: "Manage backups",23+ Short: "Manage app backups",
30- Long: `Manage backups.24+ Long: `Manage app backups.
31 25
32-Runs locally via in-process services by default, or against a remote Gordon26+Backup targets come from the app manifest: a service declares its databases
33-instance when --remote targeting is configured.`,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.`,
34 }30 }
35 31
36- cmd.AddCommand(newBackupDatabasesCmd())
37- cmd.AddCommand(newBackupVolumesCmd())
38-
39- // Compatibility aliases for the original database backup command shape.
40 cmd.AddCommand(newBackupListCmd())32 cmd.AddCommand(newBackupListCmd())
41 cmd.AddCommand(newBackupRunCmd())33 cmd.AddCommand(newBackupRunCmd())
42- cmd.AddCommand(newBackupDetectCmd())
43 cmd.AddCommand(newBackupStatusCmd())34 cmd.AddCommand(newBackupStatusCmd())
35+ cmd.AddCommand(newBackupVolumeCmd())
44 36
45 return cmd37 return cmd
46 }38 }
47 39
48-func newBackupDatabasesCmd() *cobra.Command {40+// newBackupVolumeCmd creates the `backup volume` group.
49- cmd := &cobra.Command{41+func newBackupVolumeCmd() *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 {
62 cmd := &cobra.Command{42 cmd := &cobra.Command{
63- Use: "volumes",43+ Use: "volume",
64- Short: "Manage volume backups",44+ Short: "Manage app volume backups",
65 }45 }
66 cmd.AddCommand(newVolumeBackupListCmd())46 cmd.AddCommand(newVolumeBackupListCmd())
67 cmd.AddCommand(newVolumeBackupRunCmd())47 cmd.AddCommand(newVolumeBackupRunCmd())
@@ -73,44 +53,36 @@ func newBackupListCmd() *cobra.Command {
73 var jsonOut bool53 var jsonOut bool
74 54
75 cmd := &cobra.Command{55 cmd := &cobra.Command{
76- Use: "list [domain]",56+ Use: "list [APP]",
77- Short: "List backups",57+ Short: "List database backups",
78- Long: cliRenderMuted("List backups for all domains or a specific domain."),58+ Long: cliRenderMuted("List stored backups for all apps, or for one app."),
79 Args: cobra.MaximumNArgs(1),59 Args: cobra.MaximumNArgs(1),
80 RunE: func(cmd *cobra.Command, args []string) error {60 RunE: func(cmd *cobra.Command, args []string) error {
81- domainName := ""61+ app := ""
82 if len(args) == 1 {62 if len(args) == 1 {
83- domainName = args[0]63+ app = 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)
94 }64 }
65+ handle, err := resolveControlPlane(cliConfigPath)
95 if err != nil {66 if err != nil {
96 return err67 return err
97 }68 }
98 defer handle.close()69 defer handle.close()
99-70+ return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
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)
106 },71 },
107 }72 }
108 73
109 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")74 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
110-
111 return cmd75 return cmd
112 }76 }
113 77
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+
114 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {86 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
115 if len(jobs) == 0 {87 if len(jobs) == 0 {
116 if jsonOut {88 if jsonOut {
@@ -127,106 +99,192 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
127 return err99 return err
128 }100 }
129 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)101 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 {
131 return err103 return err
132 }104 }
133-
134 for _, job := range jobs {105 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 {
136 return err108 return err
137 }109 }
138 }110 }
139 return w.Flush()111 return w.Flush()
140 }112 }
141 113
142-func newVolumeBackupListCmd() *cobra.Command {114+func newBackupRunCmd() *cobra.Command {
115+ var service string
116+ var database string
143 var jsonOut bool117 var jsonOut bool
144 118
145 cmd := &cobra.Command{119 cmd := &cobra.Command{
146- Use: "list [domain]",120+ Use: "run APP",
147- Short: "List volume backups",121+ Short: "Run a database backup now",
148- Args: cobra.MaximumNArgs(1),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),
149 RunE: func(cmd *cobra.Command, args []string) error {127 RunE: func(cmd *cobra.Command, args []string) error {
150- domainName := ""128+ handle, err := resolveControlPlane(cliConfigPath)
151- if len(args) == 1 {
152- domainName = args[0]
153- }
154- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
155 if err != nil {129 if err != nil {
156 return err130 return err
157 }131 }
158 defer handle.close()132 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+}
159 156
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)
161 if err != nil {165 if err != nil {
162- return fmt.Errorf("failed to list volume backups: %w", err)166+ return err
163 }167 }
164- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)168+ defer handle.close()
169+ return runBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
165 },170 },
166 }171 }
167 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")172 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168 return cmd173 return cmd
169 }174 }
170 175
171-func newVolumeBackupRunCmd() *cobra.Command {176+func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
172- var volumeName string177+ 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 {
173 var jsonOut bool185 var jsonOut bool
174 186
175 cmd := &cobra.Command{187 cmd := &cobra.Command{
176- Use: "run [domain]",188+ Use: "list [APP]",
177- Short: "Run volume backups now",189+ Short: "List volume backups",
178 Args: cobra.MaximumNArgs(1),190 Args: cobra.MaximumNArgs(1),
179 RunE: func(cmd *cobra.Command, args []string) error {191 RunE: func(cmd *cobra.Command, args []string) error {
180- domainName := ""192+ app := ""
181 if len(args) == 1 {193 if len(args) == 1 {
182- domainName = args[0]194+ app = args[0]
183 }195 }
184- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)196+ handle, err := resolveControlPlane(cliConfigPath)
185 if err != nil {197 if err != nil {
186 return err198 return err
187 }199 }
188 defer handle.close()200 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+}
189 207
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)
191 if err != nil {231 if err != nil {
192- if result != nil && len(result.Backups) > 0 {232+ return err
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)
198 }233 }
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)
200 },236 },
201 }237 }
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)")
203 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")240 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204 return cmd241 return cmd
205 }242 }
206 243
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+
207 func newVolumeBackupStatusCmd() *cobra.Command {262 func newVolumeBackupStatusCmd() *cobra.Command {
208 var jsonOut bool263 var jsonOut bool
209 cmd := &cobra.Command{264 cmd := &cobra.Command{
210 Use: "status",265 Use: "status",
211 Short: "Show volume backup status",266 Short: "Show volume backup status",
212 RunE: func(cmd *cobra.Command, args []string) error {267 RunE: func(cmd *cobra.Command, args []string) error {
213- handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")268+ handle, err := resolveControlPlane(cliConfigPath)
214 if err != nil {269 if err != nil {
215 return err270 return err
216 }271 }
217 defer handle.close()272 defer handle.close()
218-273+ return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
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)
224 },274 },
225 }275 }
226 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227 return cmd277 return cmd
228 }278 }
229 279
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+
230 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {288 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231 if len(jobs) == 0 {289 if len(jobs) == 0 {
232 if jsonOut {290 if jsonOut {
@@ -241,150 +299,19 @@ func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bo
241 return err299 return err
242 }300 }
243 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)301 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 {
245 return err303 return err
246 }304 }
247 for _, job := range jobs {305 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 {
249 return err309 return err
250 }310 }
251 }311 }
252 return w.Flush()312 return w.Flush()
253 }313 }
254 314
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-
388 func formatBackupTime(t *time.Time) string {315 func formatBackupTime(t *time.Time) string {
389 if t == nil {316 if t == nil {
390 return ""317 return ""
M internal/adapters/in/cli/backup_volume_test.go +13 -63
@@ -13,86 +13,36 @@ import (
13 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"13 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
14 )14 )
15 15
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 {
17 t.Helper()19 t.Helper()
18- old := backupResolveControlPlane20+ return climocks.NewMockControlPlane(t)
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())
60 }21 }
61 22
62 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {23 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
63- plane := climocks.NewMockControlPlane(t)24+ plane := backupRunPlane(t)
64- withBackupControlPlane(t, plane)
65 25
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)
67 27
68- cmd := newVolumeBackupStatusCmd()
69 var out bytes.Buffer28 var out bytes.Buffer
70- cmd.SetOut(&out)29+ require.NoError(t, runVolumeBackupStatus(context.Background(), plane, &out, true))
71- cmd.SetArgs([]string{"--json"})30+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"running","size_bytes":0}]`, out.String())
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())
75 }31 }
76 32
77 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {33 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
78- plane := climocks.NewMockControlPlane(t)34+ plane := backupRunPlane(t)
79- withBackupControlPlane(t, plane)
80 35
81 runErr := errors.New("one backup failed")36 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{
83 Status: "partial",38 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"}},
85 Error: runErr.Error(),40 Error: runErr.Error(),
86 }, runErr)41 }, runErr)
87 42
88- cmd := newVolumeBackupRunCmd()
89- cmd.SilenceUsage = true
90 var out bytes.Buffer43 var out bytes.Buffer
91- cmd.SetOut(&out)44+ err := runVolumeBackupRun(context.Background(), plane, "shop", "api", "data", &out, true)
92- cmd.SetArgs([]string{"app.example.com", "--json"})
93-
94- err := cmd.ExecuteContext(context.Background())
95 require.Error(t, err)45 require.Error(t, err)
96 require.ErrorIs(t, err, runErr)46 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())
98 }48 }
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 {
96 }96 }
97 97
98 func resolveCADataDir() (string, error) {98 func resolveCADataDir() (string, error) {
99- local, err := GetLocalServices(configPath)99+ local, err := GetLocalServices(cliConfigPath)
100 if err != nil {100 if err != nil {
101 return "", err101 return "", err
102 }102 }
M internal/adapters/in/cli/config.go +1 -37
@@ -37,7 +37,7 @@ Examples:
37 gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,37 gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
38 RunE: func(cmd *cobra.Command, _ []string) error {38 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()39 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)40+ handle, err := resolveControlPlane(cliConfigPath)
41 if err != nil {41 if err != nil {
42 return err42 return err
43 }43 }
@@ -72,9 +72,6 @@ func renderConfigTable(out io.Writer, config *remote.Config) error {
72 if err := renderConfigSummary(out, config); err != nil {72 if err := renderConfigSummary(out, config); err != nil {
73 return err73 return err
74 }74 }
75- if err := renderConfigRoutes(out, config); err != nil {
76- return err
77- }
78 return renderConfigExternalRoutes(out, config)75 return renderConfigExternalRoutes(out, config)
79 }76 }
80 77
@@ -99,9 +96,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
99 return err96 return err
100 }97 }
101 }98 }
102- if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
103- return err
104- }
105 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {99 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
106 return err100 return err
107 }101 }
@@ -113,36 +107,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
113 return nil107 return nil
114 }108 }
115 109
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-
146 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {110 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
147 if err := cliWriteLine(out, ""); err != nil {111 if err := cliWriteLine(out, ""); err != nil {
148 return err112 return err
M internal/adapters/in/cli/controlplane.go +21 -36
@@ -9,35 +9,26 @@ import (
9 )9 )
10 10
11 // ControlPlane defines command operations available to CLI execution paths.11 // ControlPlane defines command operations available to CLI execution paths.
12-//12+// Both explicit remote and local-daemon implementations call admin HTTP APIs.
13-// Remote implementations call admin HTTP APIs.
14-// Local implementations call services directly in-process.
15 type ControlPlane interface {13 type ControlPlane interface {
16- ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)14+ // App lifecycle and reads. App mutations are daemon-owned for both
17- GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)15+ // the explicit remote and the owner-only local admin socket.
18- GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)16+ ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)
19- FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)17+ ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error)
20- AddRoute(ctx context.Context, route domain.Route) error18+ ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error)
21- UpdateRoute(ctx context.Context, route domain.Route) error19+ DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error)
22- RemoveRoute(ctx context.Context, routeDomain string) error20+ DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
23- Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)21+ StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
24-22+ StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
25- ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, 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)
26 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error30 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
27 DeleteSecret(ctx context.Context, secretDomain, key string) error31 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
41 32
42 GetStatus(ctx context.Context) (*remote.Status, error)33 GetStatus(ctx context.Context) (*remote.Status, error)
43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)34 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
@@ -45,23 +36,17 @@ type ControlPlane interface {
45 Reload(ctx context.Context) error36 Reload(ctx context.Context) error
46 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)37 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
47 GetConfig(ctx context.Context) (*remote.Config, error)38 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)
51 ListTags(ctx context.Context, repository string) ([]string, error)39 ListTags(ctx context.Context, repository string) ([]string, error)
52 40
53- ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)41+ ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error)
54 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)42 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
55- RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)43+ RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error)
56- DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)44+ ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)
57- ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
58 VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)45 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)
60 47
61 GetProcessLogs(ctx context.Context, lines int) ([]string, error)48 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
62- GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
63 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)49 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
64- StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
65 50
66 ListVolumes(ctx context.Context) ([]dto.Volume, error)51 ListVolumes(ctx context.Context) ([]dto.Volume, error)
67 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)52 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 (
9 "github.com/bnema/gordon/internal/domain"9 "github.com/bnema/gordon/internal/domain"
10 )10 )
11 11
12+// remoteControlPlane delegates every CLI operation to one daemon client:
13+// the explicit remote or the owner-only local admin socket.
12 type remoteControlPlane struct {14 type remoteControlPlane struct {
13 client *remote.Client15 client *remote.Client
14 }16 }
15 17
18+// NewRemoteControlPlane creates the daemon-backed control plane.
16 func NewRemoteControlPlane(client *remote.Client) ControlPlane {19 func NewRemoteControlPlane(client *remote.Client) ControlPlane {
17 return &remoteControlPlane{client: client}20 return &remoteControlPlane{client: client}
18 }21 }
19 22
20-func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {23+func (r *remoteControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
21- return r.client.ListRoutesWithDetails(ctx)24+ return r.client.ApplyApp(ctx, req)
22 }25 }
23 26
24-func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {27+func (r *remoteControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
25- return r.client.GetHealth(ctx)28+ return r.client.ListApps(ctx)
26 }29 }
27 30
28-func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {31+func (r *remoteControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
29- return r.client.GetRoute(ctx, routeDomain)32+ return r.client.ShowApp(ctx, app)
30 }33 }
31 34
32-func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {35+func (r *remoteControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
33- return r.client.FindRoutesByImage(ctx, imageName)36+ return r.client.DiffApp(ctx, app)
34 }37 }
35 38
36-func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {39+func (r *remoteControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
37- return r.client.AddRoute(ctx, route)40+ return r.client.DeployApp(ctx, app, req)
38 }41 }
39 42
40-func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {43+func (r *remoteControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
41- return r.client.UpdateRoute(ctx, route)44+ return r.client.StopApp(ctx, app)
42 }45 }
43 46
44-func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {47+func (r *remoteControlPlane) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
45- _, err := r.RemoveRouteWithCleanup(ctx, routeDomain)48+ return r.client.StartApp(ctx, app)
46- return err
47 }49 }
48 50
49-func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {51+func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {
50- return r.client.RemoveRouteWithCleanup(ctx, routeDomain)52+ return r.client.RestartApp(ctx, app, service)
51 }53 }
52 54
53-func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {55+func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
54- return r.client.GetRouteCleanupPreview(ctx, routeDomain)56+ return r.client.RemoveApp(ctx, app)
55 }57 }
56 58
57-func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {59+func (r *remoteControlPlane) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) {
58- return r.client.Bootstrap(ctx, req)60+ return r.client.OperationByKey(ctx, app, key)
59 }61 }
60 62
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) {
62 return r.client.ListSecretsWithAttachments(ctx, secretDomain)72 return r.client.ListSecretsWithAttachments(ctx, secretDomain)
63 }73 }
64 74
@@ -70,54 +80,6 @@ func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key
70 return r.client.DeleteSecret(ctx, secretDomain, key)80 return r.client.DeleteSecret(ctx, secretDomain, key)
71 }81 }
72 82
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-
121 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {83 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
122 return r.client.GetStatus(ctx)84 return r.client.GetStatus(ctx)
123 }85 }
@@ -142,40 +104,24 @@ func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, err
142 return r.client.GetConfig(ctx)104 return r.client.GetConfig(ctx)
143 }105 }
144 106
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-
157 func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {107 func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
158 return r.client.ListTags(ctx, repository)108 return r.client.ListTags(ctx, repository)
159 }109 }
160 110
161-func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {111+func (r *remoteControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
162- return r.client.ListBackups(ctx, backupDomain)112+ return r.client.ListBackups(ctx, app)
163 }113 }
164 114
165 func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {115 func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
166 return r.client.BackupStatus(ctx)116 return r.client.BackupStatus(ctx)
167 }117 }
168 118
169-func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {119+func (r *remoteControlPlane) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) {
170- return r.client.RunBackup(ctx, backupDomain, dbName)120+ return r.client.RunBackup(ctx, app, service, database)
171-}
172-
173-func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
174- return r.client.DetectDatabases(ctx, backupDomain)
175 }121 }
176 122
177-func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {123+func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
178- jobs, err := r.client.ListVolumeBackups(ctx, backupDomain)124+ jobs, err := r.client.ListVolumeBackups(ctx, app)
179 if err != nil {125 if err != nil {
180 return nil, fmt.Errorf("list volume backups: %w", err)126 return nil, fmt.Errorf("list volume backups: %w", err)
181 }127 }
@@ -190,8 +136,8 @@ func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.Volu
190 return jobs, nil136 return jobs, nil
191 }137 }
192 138
193-func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {139+func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) {
194- result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName)140+ result, err := r.client.RunVolumeBackups(ctx, app, service, volume)
195 if err != nil {141 if err != nil {
196 return result, fmt.Errorf("run volume backups: %w", err)142 return result, fmt.Errorf("run volume backups: %w", err)
197 }143 }
@@ -202,18 +148,10 @@ func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]s
202 return r.client.GetProcessLogs(ctx, lines)148 return r.client.GetProcessLogs(ctx, lines)
203 }149 }
204 150
205-func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
206- return r.client.GetContainerLogs(ctx, logDomain, lines)
207-}
208-
209 func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {151 func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
210 return r.client.StreamProcessLogs(ctx, lines)152 return r.client.StreamProcessLogs(ctx, lines)
211 }153 }
212 154
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-
217 func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {155 func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
218 return r.client.ListVolumes(ctx)156 return r.client.ListVolumes(ctx)
219 }157 }
M internal/adapters/in/cli/controlplane_remote_test.go +2 -2
@@ -30,14 +30,14 @@ func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T)
30 w.WriteHeader(http.StatusPartialContent)30 w.WriteHeader(http.StatusPartialContent)
31 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{31 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
32 Status: "partial",32 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"}},
34 Error: "one volume failed",34 Error: "one volume failed",
35 }))35 }))
36 }))36 }))
37 t.Cleanup(server.Close)37 t.Cleanup(server.Close)
38 38
39 cp := NewRemoteControlPlane(remote.NewClient(server.URL))39 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")
41 41
42 require.Error(t, err)42 require.Error(t, err)
43 assert.Contains(t, err.Error(), "run volume backups")43 assert.Contains(t, err.Error(), "run volume backups")
M internal/adapters/in/cli/controlplane_resolver.go +20 -36
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8- "github.com/bnema/gordon/internal/app"
9 )8 )
10 9
11 type controlPlaneHandle struct {10 type controlPlaneHandle struct {
@@ -24,16 +23,27 @@ func (h *controlPlaneHandle) close() {
24 }23 }
25 }24 }
26 25
27-func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {26+var newLocalControlPlaneClient = remote.NewLocalClient
27+
28+func resolveDaemonClient() (*remote.Client, bool, error) {
28 client, isRemote, err := GetRemoteClient()29 client, isRemote, err := GetRemoteClient()
29- if err != nil {30+ if err != nil || isRemote {
30- return nil, err31+ return client, isRemote, err
31 }32 }
32- if isRemote {33+
33- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil34+ client, err = newLocalControlPlaneClient()
35+ if err != nil {
36+ return nil, false, fmt.Errorf("local control plane unavailable: %w", err)
34 }37 }
38+ return client, false, nil
39+}
35 40
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
37 }47 }
38 48
39 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {49 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {
@@ -41,22 +51,8 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan
41 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}51 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}
42 }52 }
43 53
44-func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {54+func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) {
45- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {55+ return resolveControlPlaneWithInference(ctx, inferExplicitTarget)
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- })
60 }56 }
61 57
62 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {58 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
@@ -73,17 +69,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co
73 if resolved != nil {69 if resolved != nil {
74 return newRemoteControlPlaneHandle(resolved), nil70 return newRemoteControlPlaneHandle(resolved), nil
75 }71 }
76- return resolveControlPlane(configPath)72+ return resolveControlPlane(cliConfigPath)
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
89 }73 }
M internal/adapters/in/cli/controlplane_resolver_test.go +94 -70
@@ -1,110 +1,134 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
4 "os"8 "os"
5 "path/filepath"9 "path/filepath"
6 "testing"10 "testing"
11+ "time"
7 12
13+ "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/localadmin"
9 )18 )
10 19
11-func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {20+func isolateControlPlaneResolver(t *testing.T) {
12- tmpDir := t.TempDir()21+ t.Helper()
13- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))22+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
14 t.Setenv("GORDON_REMOTE", "")23 t.Setenv("GORDON_REMOTE", "")
15 t.Setenv("GORDON_TOKEN", "")24 t.Setenv("GORDON_TOKEN", "")
16 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag25 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
26+ originalFactory := newLocalControlPlaneClient
17 t.Cleanup(func() {27 t.Cleanup(func() {
18 remoteFlag = originalRemoteFlag28 remoteFlag = originalRemoteFlag
19 tokenFlag = originalTokenFlag29 tokenFlag = originalTokenFlag
20 insecureTLSFlag = originalInsecureTLSFlag30 insecureTLSFlag = originalInsecureTLSFlag
31+ newLocalControlPlaneClient = originalFactory
21 })32 })
22-
23 remoteFlag = ""33 remoteFlag = ""
24 tokenFlag = ""34 tokenFlag = ""
25 insecureTLSFlag = false35 insecureTLSFlag = false
36+}
26 37
27- configPath := filepath.Join(tmpDir, "gordon.toml")38+func localControlPlaneTestClient(t *testing.T, handler http.Handler) *remote.Client {
28- err := os.WriteFile(configPath, []byte(`[server]39+ t.Helper()
29-gordon_domain = "gordon.local"40+ dir := t.TempDir()
30-data_dir = "`+filepath.Join(tmpDir, "data")+`"41+ listener, _, err := localadmin.Listen(dir)
31-
32-[auth]
33-enabled = true
34-secrets_backend = "unsafe"
35-`), 0o600)
36 require.NoError(t, err)42 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+}
37 48
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"))
39 require.NoError(t, err)65 require.NoError(t, err)
40 require.NotNil(t, handle)66 require.NotNil(t, handle)
41- require.NotNil(t, handle.plane)67+ assert.False(t, handle.isRemote)
42- defer handle.close()68+ assert.IsType(t, &remoteControlPlane{}, handle.plane)
43-}69+ assert.Equal(t, 1, factoryCalls)
44 70
45-func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {71+ status, err := handle.plane.GetStatus(context.Background())
46- tmpDir := t.TempDir()72+ require.NoError(t, err)
47- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))73+ assert.Equal(t, "socket.local", status.RegistryDomain)
48- t.Setenv("GORDON_REMOTE", "")74+ select {
49- t.Setenv("GORDON_TOKEN", "")75+ case path := <-requests:
50- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag76+ assert.Equal(t, "/admin/status", path)
51- t.Cleanup(func() {77+ case <-time.After(2 * time.Second):
52- remoteFlag = originalRemoteFlag78+ t.Fatal("socket server received no request")
53- tokenFlag = originalTokenFlag79+ }
54- insecureTLSFlag = originalInsecureTLSFlag80+}
55- })
56 81
57- remoteFlag = ""82+func TestResolveControlPlane_LocalFailsClosedWhenDaemonUnavailable(t *testing.T) {
58- tokenFlag = ""83+ isolateControlPlaneResolver(t)
59- insecureTLSFlag = false84+ newLocalControlPlaneClient = func() (*remote.Client, error) {
85+ return nil, remote.ErrDaemonUnavailable
86+ }
60 87
61- configPath := filepath.Join(tmpDir, "gordon.toml")88+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "would-create-store.toml"))
62- err := os.WriteFile(configPath, []byte(`[server]89+ require.Error(t, err)
63-gordon_domain = "gordon.local"90+ assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
64-data_dir = "`+filepath.Join(tmpDir, "data")+`"91+ assert.Nil(t, handle)
92+}
65 93
66-[auth]94+func TestResolveControlPlane_ExplicitRemoteDoesNotUseLocalFactory(t *testing.T) {
67-enabled = false95+ isolateControlPlaneResolver(t)
68-secrets_backend = "unsafe"96+ remoteFlag = "https://example.invalid"
69-`), 0o600)97+ tokenFlag = "token"
70- require.NoError(t, err)98+ newLocalControlPlaneClient = func() (*remote.Client, error) {
99+ t.Fatal("local factory invoked for explicit remote")
100+ return nil, errors.New("unreachable")
101+ }
71 102
72- handle, err := resolveControlPlane(configPath)103+ handle, err := resolveControlPlane("")
73 require.NoError(t, err)104 require.NoError(t, err)
74- require.NotNil(t, handle)105+ assert.True(t, handle.isRemote)
75- require.NotNil(t, handle.plane)106+ assert.IsType(t, &remoteControlPlane{}, handle.plane)
76- defer handle.close()
77 }107 }
78 108
79 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {109 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80- tmpDir := t.TempDir()110+ isolateControlPlaneResolver(t)
81- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))111+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "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-
91 remoteFlag = "does-not-exist"112 remoteFlag = "does-not-exist"
92- tokenFlag = ""
93- insecureTLSFlag = false
94 113
95- configPath := filepath.Join(tmpDir, "gordon.toml")114+ handle, err := resolveControlPlane("")
96- err := os.WriteFile(configPath, []byte(`[server]115+ require.Error(t, err)
97-gordon_domain = "gordon.local"116+ assert.Nil(t, handle)
98-data_dir = "`+filepath.Join(tmpDir, "data")+`"117+ assert.Contains(t, err.Error(), "does-not-exist")
118+}
99 119
100-[auth]120+func TestResolveControlPlane_LocalIgnoresConfigAndStoreState(t *testing.T) {
101-enabled = false121+ isolateControlPlaneResolver(t)
102-secrets_backend = "unsafe"122+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
103-`), 0o600)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())
104 require.NoError(t, err)133 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")
110 }134 }
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 {
70 cmd := &cobra.Command{70 cmd := &cobra.Command{
71 Use: "images",71 Use: "images",
72 Short: "List and prune images",72 Short: "List and prune images",
73- Long: `Inspect and clean up runtime and registry images.73+ Long: `Inspect and clean up runtime and registry images through the selected daemon.`,
74-
75-These commands currently require remote mode with a configured target.`,
76 }74 }
77 75
78 cmd.AddCommand(newImagesListCmd())76 cmd.AddCommand(newImagesListCmd())
@@ -89,14 +87,10 @@ func newImagesListCmd() *cobra.Command {
89 Use: "list",87 Use: "list",
90 Short: "List runtime images and registry tags",88 Short: "List runtime images and registry tags",
91 RunE: func(cmd *cobra.Command, _ []string) error {89 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote, err := GetRemoteClient()90+ client, _, err := resolveDaemonClient()
93 if err != nil {91 if err != nil {
94 return err92 return err
95 }93 }
96- if !isRemote {
97- return fmt.Errorf("images commands require a configured remote target")
98- }
99-
100 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)94 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
101 },95 },
102 }96 }
@@ -115,21 +109,21 @@ func newImagesPruneCmd() *cobra.Command {
115 Long: `Remove dangling runtime images and/or old registry tags.109 Long: `Remove dangling runtime images and/or old registry tags.
116 110
117 By default both runtime and registry cleanup run, keeping latest + 3 previous111 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.`,
119 RunE: func(cmd *cobra.Command, _ []string) error {117 RunE: func(cmd *cobra.Command, _ []string) error {
120- client, isRemote, err := GetRemoteClient()118+ client, _, err := resolveDaemonClient()
121 if err != nil {119 if err != nil {
122 return err120 return err
123 }121 }
124- if !isRemote {
125- return fmt.Errorf("images commands require a configured remote target")
126- }
127-
128 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())122 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
129 },123 },
130 }124 }
131 125
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")
133 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,127 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
134 "Number of previous non-latest tags to keep per repository (latest is always kept)")128 "Number of previous non-latest tags to keep per repository (latest is always kept)")
135 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")129 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
357 }351 }
358 }352 }
359 353
360- req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)354+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, false)
361 355
362 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)356 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
363 if err != nil {357 if err != nil {
@@ -378,12 +372,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
378 }372 }
379 373
380 if pruneRegistry {374 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 {
382 return err379 return err
383 }380 }
384 381
385- err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))382+ return cliRenderPruneSummary(out, resp.Plan)
386- return err
387 }383 }
388 384
389 func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {385 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
391 return err387 return err
392 }388 }
393 389
394- if pruneDangling {390+ // A dry run executes the full inventory and planning path: only the
395- images, err := client.ListImages(ctx)391+ // deletion step is skipped, so the report is the executed plan.
396- if err != nil {392+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true)
397- return fmt.Errorf("failed to list images: %w", err)393+ resp, err := client.PruneImages(ctx, req)
398- }394+ if err != nil {
399-395+ return fmt.Errorf("failed to plan image prune: %w", err)
400- danglingCount := 0396+ }
401- for _, img := range images {397+ if resp == nil {
402- if img.Dangling {398+ return fmt.Errorf("failed to plan image prune: empty response")
403- danglingCount++399+ }
404- }
405- }
406 400
407- if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {401+ if pruneDangling {
408- return err402+ if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil {
409- }
410- } else {
411- if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
412 return err403 return err
413 }404 }
405+ } else if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
406+ return err
414 }407 }
415 408
416 if pruneRegistry {409 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
418 }415 }
419 416
420- return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))417+ return cliRenderPruneSummary(out, resp.Plan)
421 }418 }
422 419
423 func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {420 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]
561 return kept558 return kept
562 }559 }
563 560
564-func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {561+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry, dryRun bool) dto.ImagePruneRequest {
565 return dto.ImagePruneRequest{562 return dto.ImagePruneRequest{
566 KeepLast: &keepReleases,563 KeepLast: &keepReleases,
567 PruneDangling: &pruneDangling,564 PruneDangling: &pruneDangling,
568 PruneRegistry: &pruneRegistry,565 PruneRegistry: &pruneRegistry,
566+ DryRun: &dryRun,
569 }567 }
570 }568 }
571 569
M internal/adapters/in/cli/images_test.go +35 -12
@@ -297,7 +297,18 @@ func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
297 297
298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299 client := &imagesClientMock{299 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+ },
301 }312 }
302 313
303 var out bytes.Buffer314 var out bytes.Buffer
@@ -307,15 +318,22 @@ func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
307 318
308 text := out.String()319 text := out.String()
309 assert.Contains(t, text, "Dry run")320 assert.Contains(t, text, "Dry run")
310- assert.Contains(t, text, "would prune 2 dangling runtime images")321+ assert.Contains(t, text, "would delete 2 images")
311- assert.Contains(t, text, "would keep latest + 3 previous tags")322+ assert.Contains(t, text, "latest + 3 previous tags")
312- assert.Equal(t, 1, client.listImagesCalls)323+ assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
313- assert.Equal(t, 0, client.pruneCalls)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)
314 }328 }
315 329
316 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {330 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317 client := &imagesClientMock{331 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+ },
319 }337 }
320 338
321 var out bytes.Buffer339 var out bytes.Buffer
@@ -324,14 +342,18 @@ func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
324 require.NoError(t, err)342 require.NoError(t, err)
325 343
326 text := out.String()344 text := out.String()
327- assert.Contains(t, text, "would prune 1 dangling runtime images")345+ assert.Contains(t, text, "would delete 1 images")
328 assert.Contains(t, text, "Registry cleanup skipped")346 assert.Contains(t, text, "Registry cleanup skipped")
329- assert.Equal(t, 0, client.pruneCalls)347+ assert.Equal(t, 1, client.pruneCalls)
330 }348 }
331 349
332 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {350 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333 client := &imagesClientMock{351 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+ },
335 }357 }
336 358
337 var out bytes.Buffer359 var out bytes.Buffer
@@ -341,8 +363,8 @@ func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
341 363
342 text := out.String()364 text := out.String()
343 assert.Contains(t, text, "Runtime cleanup skipped")365 assert.Contains(t, text, "Runtime cleanup skipped")
344- assert.Contains(t, text, "would keep latest + 5 previous tags")366+ assert.Contains(t, text, "latest + 5 previous tags")
345- assert.Equal(t, 0, client.pruneCalls)367+ assert.Equal(t, 1, client.pruneCalls)
346 }368 }
347 369
348 // ---------------------------------------------------------------------------370 // ---------------------------------------------------------------------------
@@ -484,7 +506,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
484 t.Cleanup(func() { pruneConfirmFunc = origConfirm })506 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485 507
486 client := &imagesClientMock{508 client := &imagesClientMock{
487- listImagesResp: []dto.Image{{Dangling: true}},509+ pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
488 }510 }
489 511
490 var out bytes.Buffer512 var out bytes.Buffer
@@ -493,6 +515,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
493 require.NoError(t, err)515 require.NoError(t, err)
494 516
495 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")517 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518+ assert.Equal(t, 1, client.pruneCalls)
496 }519 }
497 520
498 // ---------------------------------------------------------------------------521 // ---------------------------------------------------------------------------
M internal/adapters/in/cli/json_parity_test.go +0 -5
@@ -12,9 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
12 name string12 name string
13 builder func() *cobra.Command13 builder func() *cobra.Command
14 }{14 }{
15- {"routes list", newRoutesListCmd},
16- {"routes status", newRoutesStatusCmd},
17- {"attachments list", newAttachmentsListCmd},
18 {"secrets list", newSecretsListCmd},15 {"secrets list", newSecretsListCmd},
19 {"images list", newImagesListCmd},16 {"images list", newImagesListCmd},
20 {"backup list", newBackupListCmd},17 {"backup list", newBackupListCmd},
@@ -23,9 +20,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
23 {"backup volumes status", newVolumeBackupStatusCmd},20 {"backup volumes status", newVolumeBackupStatusCmd},
24 {"auth token list", newTokenListCmd},21 {"auth token list", newTokenListCmd},
25 {"remotes list", newRemotesListCmd},22 {"remotes list", newRemotesListCmd},
26- {"pin list", newPinListCmd},
27 {"config show", newConfigShowCmd},23 {"config show", newConfigShowCmd},
28- {"routes show", newRoutesShowCmd},
29 {"networks list", newNetworksListCmd},24 {"networks list", newNetworksListCmd},
30 {"images tags", newImagesTagsCmd},25 {"images tags", newImagesTagsCmd},
31 {"tls status", newTLSStatusCmd},26 {"tls status", newTLSStatusCmd},
M internal/adapters/in/cli/json_test.go +6 -169
@@ -14,24 +14,6 @@ import (
14 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15 )15 )
16 16
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-
35 func TestSecretsList_JSONFlag_Accepted(t *testing.T) {17 func TestSecretsList_JSONFlag_Accepted(t *testing.T) {
36 cmd := newSecretsListCmd()18 cmd := newSecretsListCmd()
37 f := cmd.Flags().Lookup("json")19 f := cmd.Flags().Lookup("json")
@@ -77,15 +59,6 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
77 }59 }
78 }60 }
79 61
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-
89 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {62 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
90 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)63 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
91 images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}64 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) {
113 86
114 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {87 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
115 startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)88 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}}
117 90
118 payload, err := json.Marshal(jobs)91 payload, err := json.Marshal(jobs)
119 require.NoError(t, err)92 require.NoError(t, err)
@@ -122,7 +95,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
122 require.NoError(t, json.Unmarshal(payload, &got))95 require.NoError(t, json.Unmarshal(payload, &got))
123 require.Len(t, got, 1)96 require.Len(t, got, 1)
124 assert.Equal(t, "b1", got[0].ID)97 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)
126 }101 }
127 102
128 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {103 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
@@ -172,157 +147,19 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
172 assert.True(t, got[0].InsecureTLS)147 assert.True(t, got[0].InsecureTLS)
173 }148 }
174 149
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-
196 func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {150 func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {
197- payload := struct {151+ payload := remote.SecretsListResult{
198- Domain string `json:"domain"`
199- Keys []string `json:"keys"`
200- Attachments []remote.AttachmentSecrets `json:"attachments"`
201- }{
202 Domain: "app.example.com",152 Domain: "app.example.com",
203 Keys: []string{"API_KEY"},153 Keys: []string{"API_KEY"},
204- Attachments: []remote.AttachmentSecrets{{
205- Service: "postgres",
206- Keys: []string{"POSTGRES_PASSWORD"},
207- }},
208 }154 }
209 155
210 encoded, err := json.Marshal(payload)156 encoded, err := json.Marshal(payload)
211 require.NoError(t, err)157 require.NoError(t, err)
212 158
213- var got struct {159+ var got remote.SecretsListResult
214- Domain string `json:"domain"`
215- Keys []string `json:"keys"`
216- Attachments []remote.AttachmentSecrets `json:"attachments"`
217- }
218 require.NoError(t, json.Unmarshal(encoded, &got))160 require.NoError(t, json.Unmarshal(encoded, &got))
219 assert.Equal(t, payload.Domain, got.Domain)161 assert.Equal(t, payload.Domain, got.Domain)
220 assert.Equal(t, payload.Keys, got.Keys)162 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)
326 }163 }
327 164
328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {165 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
M internal/adapters/in/cli/local.go +2 -2
@@ -49,7 +49,7 @@ func (l *LocalServices) HasInternalTLS() bool {
49 49
50 // GetLocalServices creates local services for CLI operations.50 // GetLocalServices creates local services for CLI operations.
51 // It loads the config and initializes services without starting the server.51 // 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) {
53 // Set up viper with defaults53 // Set up viper with defaults
54 v := viper.New()54 v := viper.New()
55 v.SetDefault("server.port", 8088)55 v.SetDefault("server.port", 8088)
@@ -57,7 +57,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
57 v.SetDefault("server.data_dir", app.DefaultDataDir())57 v.SetDefault("server.data_dir", app.DefaultDataDir())
58 58
59 // Configure viper with config file paths59 // Configure viper with config file paths
60- app.ConfigureViper(v, configPath)60+ app.ConfigureViper(v, cliConfigPath)
61 61
62 // Read config file62 // Read config file
63 if err := v.ReadInConfig(); err != nil {63 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 {
40 return &MockControlPlane_Expecter{mock: &_m.Mock}40 return &MockControlPlane_Expecter{mock: &_m.Mock}
41 }41 }
42 42
43-// AddAttachment provides a mock function for the type MockControlPlane43+// ApplyApp provides a mock function for the type MockControlPlane
44-func (_mock *MockControlPlane) AddAttachment(ctx context.Context, domainOrGroup string, image string) error {44+func (_mock *MockControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
45- ret := _mock.Called(ctx, domainOrGroup, image)45+ ret := _mock.Called(ctx, req)
46 46
47 if len(ret) == 0 {47 if len(ret) == 0 {
48- panic("no return value specified for AddAttachment")48+ panic("no return value specified for ApplyApp")
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)
56 }49 }
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)
109 50
110- if len(ret) == 0 {51+ var r0 *dto.AppApplyResponse
111- panic("no return value specified for AddAutoRouteAllowedDomain")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)
112 }55 }
113-56+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.AppApplyRequest) *dto.AppApplyResponse); ok {
114- var r0 error57+ r0 = returnFunc(ctx, req)
115- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
116- r0 = returnFunc(ctx, pattern)
117 } else {58 } else {
118- r0 = ret.Error(0)59+ if ret.Get(0) != nil {
119- }60+ r0 = ret.Get(0).(*dto.AppApplyResponse)
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)
144 }61 }
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")
169 }62 }
170-63+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.AppApplyRequest) error); ok {
171- var r0 error64+ r1 = returnFunc(ctx, req)
172- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {
173- r0 = returnFunc(ctx, route)
174 } else {65 } else {
175- r0 = ret.Error(0)66+ r1 = ret.Error(1)
176 }67 }
177- return r068+ return r0, r1
178 }69 }
179 70
180-// MockControlPlane_AddRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRoute'71+// MockControlPlane_ApplyApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyApp'
181-type MockControlPlane_AddRoute_Call struct {72+type MockControlPlane_ApplyApp_Call struct {
182 *mock.Call73 *mock.Call
183 }74 }
184 75
185-// AddRoute is a helper method to define mock.On call76+// ApplyApp is a helper method to define mock.On call
186 // - ctx context.Context77 // - ctx context.Context
187-// - route domain.Route78+// - req dto.AppApplyRequest
188-func (_e *MockControlPlane_Expecter) AddRoute(ctx any, route any) *MockControlPlane_AddRoute_Call {79+func (_e *MockControlPlane_Expecter) ApplyApp(ctx any, req any) *MockControlPlane_ApplyApp_Call {
189- return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}80+ return &MockControlPlane_ApplyApp_Call{Call: _e.mock.On("ApplyApp", ctx, req)}
190 }81 }
191 82
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 {
193 _c.Call.Run(func(args mock.Arguments) {84 _c.Call.Run(func(args mock.Arguments) {
194 var arg0 context.Context85 var arg0 context.Context
195 if args[0] != nil {86 if args[0] != nil {
196 arg0 = args[0].(context.Context)87 arg0 = args[0].(context.Context)
197 }88 }
198- var arg1 domain.Route89+ var arg1 dto.AppApplyRequest
199 if args[1] != nil {90 if args[1] != nil {
200- arg1 = args[1].(domain.Route)91+ arg1 = args[1].(dto.AppApplyRequest)
201 }92 }
202 run(93 run(
203 arg0,94 arg0,
@@ -207,12 +98,12 @@ func (_c *MockControlPlane_AddRoute_Call) Run(run func(ctx context.Context, rout
207 return _c98 return _c
208 }99 }
209 100
210-func (_c *MockControlPlane_AddRoute_Call) Return(err error) *MockControlPlane_AddRoute_Call {101+func (_c *MockControlPlane_ApplyApp_Call) Return(appApplyResponse *dto.AppApplyResponse, err error) *MockControlPlane_ApplyApp_Call {
211- _c.Call.Return(err)102+ _c.Call.Return(appApplyResponse, err)
212 return _c103 return _c
213 }104 }
214 105
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 {
216 _c.Call.Return(run)107 _c.Call.Return(run)
217 return _c108 return _c
218 }109 }
@@ -279,180 +170,37 @@ func (_c *MockControlPlane_BackupStatus_Call) RunAndReturn(run func(ctx context.
279 return _c170 return _c
280 }171 }
281 172
282-// Bootstrap provides a mock function for the type MockControlPlane173+// DeleteAppSecret provides a mock function for the type MockControlPlane
283-func (_mock *MockControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {174+func (_mock *MockControlPlane) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error {
284- ret := _mock.Called(ctx, req)175+ ret := _mock.Called(ctx, app, 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)
427 176
428 if len(ret) == 0 {177 if len(ret) == 0 {
429- panic("no return value specified for DeleteAttachmentSecret")178+ panic("no return value specified for DeleteAppSecret")
430 }179 }
431 180
432 var r0 error181 var r0 error
433- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {182+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretDeleteRequest) error); ok {
434- r0 = returnFunc(ctx, domain1, service, key)183+ r0 = returnFunc(ctx, app, req)
435 } else {184 } else {
436 r0 = ret.Error(0)185 r0 = ret.Error(0)
437 }186 }
438 return r0187 return r0
439 }188 }
440 189
441-// MockControlPlane_DeleteAttachmentSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachmentSecret'190+// MockControlPlane_DeleteAppSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAppSecret'
442-type MockControlPlane_DeleteAttachmentSecret_Call struct {191+type MockControlPlane_DeleteAppSecret_Call struct {
443 *mock.Call192 *mock.Call
444 }193 }
445 194
446-// DeleteAttachmentSecret is a helper method to define mock.On call195+// DeleteAppSecret is a helper method to define mock.On call
447 // - ctx context.Context196 // - ctx context.Context
448-// - domain1 string197+// - app string
449-// - service string198+// - req dto.AppSecretDeleteRequest
450-// - key string199+func (_e *MockControlPlane_Expecter) DeleteAppSecret(ctx any, app any, req any) *MockControlPlane_DeleteAppSecret_Call {
451-func (_e *MockControlPlane_Expecter) DeleteAttachmentSecret(ctx any, domain1 any, service any, key any) *MockControlPlane_DeleteAttachmentSecret_Call {200+ return &MockControlPlane_DeleteAppSecret_Call{Call: _e.mock.On("DeleteAppSecret", ctx, app, req)}
452- return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)}
453 }201 }
454 202
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 {
456 _c.Call.Run(func(args mock.Arguments) {204 _c.Call.Run(func(args mock.Arguments) {
457 var arg0 context.Context205 var arg0 context.Context
458 if args[0] != nil {206 if args[0] != nil {
@@ -462,30 +210,25 @@ func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Run(run func(ctx context
462 if args[1] != nil {210 if args[1] != nil {
463 arg1 = args[1].(string)211 arg1 = args[1].(string)
464 }212 }
465- var arg2 string213+ var arg2 dto.AppSecretDeleteRequest
466 if args[2] != nil {214 if args[2] != nil {
467- arg2 = args[2].(string)215+ arg2 = args[2].(dto.AppSecretDeleteRequest)
468- }
469- var arg3 string
470- if args[3] != nil {
471- arg3 = args[3].(string)
472 }216 }
473 run(217 run(
474 arg0,218 arg0,
475 arg1,219 arg1,
476 arg2,220 arg2,
477- arg3,
478 )221 )
479 })222 })
480 return _c223 return _c
481 }224 }
482 225
483-func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Return(err error) *MockControlPlane_DeleteAttachmentSecret_Call {226+func (_c *MockControlPlane_DeleteAppSecret_Call) Return(err error) *MockControlPlane_DeleteAppSecret_Call {
484 _c.Call.Return(err)227 _c.Call.Return(err)
485 return _c228 return _c
486 }229 }
487 230
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 {
489 _c.Call.Return(run)232 _c.Call.Return(run)
490 return _c233 return _c
491 }234 }
@@ -553,631 +296,127 @@ func (_c *MockControlPlane_DeleteSecret_Call) RunAndReturn(run func(ctx context.
553 return _c296 return _c
554 }297 }
555 298
556-// Deploy provides a mock function for the type MockControlPlane299+// DeployApp provides a mock function for the type MockControlPlane
557-func (_mock *MockControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {300+func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
558- ret := _mock.Called(ctx, deployDomain)301+ ret := _mock.Called(ctx, app, req)
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)
627 302
628 if len(ret) == 0 {303 if len(ret) == 0 {
629- panic("no return value specified for DeployIntent")304+ panic("no return value specified for DeployApp")
630 }305 }
631 306
632- var r0 error307+ var r0 *dto.AppDeployResponse
633- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {308+ var r1 string
634- r0 = returnFunc(ctx, imageName)309+ var r2 error
635- } else {310+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)); ok {
636- r0 = ret.Error(0)311+ return returnFunc(ctx, app, req)
637 }312 }
638- return r0313+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) *dto.AppDeployResponse); ok {
639-}314+ r0 = returnFunc(ctx, app, req)
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)
1092 } else {315 } else {
1093 if ret.Get(0) != nil {316 if ret.Get(0) != nil {
1094- r0 = ret.Get(0).(*remote.Config)317+ r0 = ret.Get(0).(*dto.AppDeployResponse)
1095 }318 }
1096 }319 }
1097- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {320+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, dto.AppDeployRequest) string); ok {
1098- r1 = returnFunc(ctx)321+ r1 = returnFunc(ctx, app, req)
1099 } else {322 } else {
1100- r1 = ret.Error(1)323+ r1 = ret.Get(1).(string)
1101 }324 }
1102- return r0, r1325+ 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
1103 }331 }
1104 332
1105-// MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig'333+// MockControlPlane_DeployApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployApp'
1106-type MockControlPlane_GetConfig_Call struct {334+type MockControlPlane_DeployApp_Call struct {
1107 *mock.Call335 *mock.Call
1108 }336 }
1109 337
1110-// GetConfig is a helper method to define mock.On call338+// DeployApp is a helper method to define mock.On call
1111 // - ctx context.Context339 // - ctx context.Context
1112-func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call {340+// - app string
1113- return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}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)}
1114 }344 }
1115 345
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 {
1117 _c.Call.Run(func(args mock.Arguments) {347 _c.Call.Run(func(args mock.Arguments) {
1118 var arg0 context.Context348 var arg0 context.Context
1119 if args[0] != nil {349 if args[0] != nil {
1120 arg0 = args[0].(context.Context)350 arg0 = args[0].(context.Context)
1121 }351 }
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+ }
1122 run(360 run(
1123 arg0,361 arg0,
362+ arg1,
363+ arg2,
1124 )364 )
1125 })365 })
1126 return _c366 return _c
1127 }367 }
1128 368
1129-func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call {369+func (_c *MockControlPlane_DeployApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_DeployApp_Call {
1130- _c.Call.Return(config, err)370+ _c.Call.Return(appDeployResponse, s, err)
1131 return _c371 return _c
1132 }372 }
1133 373
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 {
1135 _c.Call.Return(run)375 _c.Call.Return(run)
1136 return _c376 return _c
1137 }377 }
1138 378
1139-// GetContainerLogs provides a mock function for the type MockControlPlane379+// DiffApp provides a mock function for the type MockControlPlane
1140-func (_mock *MockControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {380+func (_mock *MockControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
1141- ret := _mock.Called(ctx, logDomain, lines)381+ ret := _mock.Called(ctx, app)
1142 382
1143 if len(ret) == 0 {383 if len(ret) == 0 {
1144- panic("no return value specified for GetContainerLogs")384+ panic("no return value specified for DiffApp")
1145 }385 }
1146 386
1147- var r0 []string387+ var r0 *dto.AppDiffResponse
1148 var r1 error388 var r1 error
1149- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]string, error)); ok {389+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDiffResponse, error)); ok {
1150- return returnFunc(ctx, logDomain, lines)390+ return returnFunc(ctx, app)
1151 }391 }
1152- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []string); ok {392+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDiffResponse); ok {
1153- r0 = returnFunc(ctx, logDomain, lines)393+ r0 = returnFunc(ctx, app)
1154 } else {394 } else {
1155 if ret.Get(0) != nil {395 if ret.Get(0) != nil {
1156- r0 = ret.Get(0).([]string)396+ r0 = ret.Get(0).(*dto.AppDiffResponse)
1157 }397 }
1158 }398 }
1159- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {399+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1160- r1 = returnFunc(ctx, logDomain, lines)400+ r1 = returnFunc(ctx, app)
1161 } else {401 } else {
1162 r1 = ret.Error(1)402 r1 = ret.Error(1)
1163 }403 }
1164 return r0, r1404 return r0, r1
1165 }405 }
1166 406
1167-// MockControlPlane_GetContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerLogs'407+// MockControlPlane_DiffApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiffApp'
1168-type MockControlPlane_GetContainerLogs_Call struct {408+type MockControlPlane_DiffApp_Call struct {
1169 *mock.Call409 *mock.Call
1170 }410 }
1171 411
1172-// GetContainerLogs is a helper method to define mock.On call412+// DiffApp is a helper method to define mock.On call
1173 // - ctx context.Context413 // - ctx context.Context
1174-// - logDomain string414+// - app string
1175-// - lines int415+func (_e *MockControlPlane_Expecter) DiffApp(ctx any, app any) *MockControlPlane_DiffApp_Call {
1176-func (_e *MockControlPlane_Expecter) GetContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_GetContainerLogs_Call {416+ return &MockControlPlane_DiffApp_Call{Call: _e.mock.On("DiffApp", ctx, app)}
1177- return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)}
1178 }417 }
1179 418
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 {
1181 _c.Call.Run(func(args mock.Arguments) {420 _c.Call.Run(func(args mock.Arguments) {
1182 var arg0 context.Context421 var arg0 context.Context
1183 if args[0] != nil {422 if args[0] != nil {
@@ -1187,47 +426,42 @@ func (_c *MockControlPlane_GetContainerLogs_Call) Run(run func(ctx context.Conte
1187 if args[1] != nil {426 if args[1] != nil {
1188 arg1 = args[1].(string)427 arg1 = args[1].(string)
1189 }428 }
1190- var arg2 int
1191- if args[2] != nil {
1192- arg2 = args[2].(int)
1193- }
1194 run(429 run(
1195 arg0,430 arg0,
1196 arg1,431 arg1,
1197- arg2,
1198 )432 )
1199 })433 })
1200 return _c434 return _c
1201 }435 }
1202 436
1203-func (_c *MockControlPlane_GetContainerLogs_Call) Return(strings []string, err error) *MockControlPlane_GetContainerLogs_Call {437+func (_c *MockControlPlane_DiffApp_Call) Return(appDiffResponse *dto.AppDiffResponse, err error) *MockControlPlane_DiffApp_Call {
1204- _c.Call.Return(strings, err)438+ _c.Call.Return(appDiffResponse, err)
1205 return _c439 return _c
1206 }440 }
1207 441
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 {
1209 _c.Call.Return(run)443 _c.Call.Return(run)
1210 return _c444 return _c
1211 }445 }
1212 446
1213-// GetHealth provides a mock function for the type MockControlPlane447+// GetConfig provides a mock function for the type MockControlPlane
1214-func (_mock *MockControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {448+func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
1215 ret := _mock.Called(ctx)449 ret := _mock.Called(ctx)
1216 450
1217 if len(ret) == 0 {451 if len(ret) == 0 {
1218- panic("no return value specified for GetHealth")452+ panic("no return value specified for GetConfig")
1219 }453 }
1220 454
1221- var r0 map[string]*remote.RouteHealth455+ var r0 *remote.Config
1222 var r1 error456 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 {
1224 return returnFunc(ctx)458 return returnFunc(ctx)
1225 }459 }
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 {
1227 r0 = returnFunc(ctx)461 r0 = returnFunc(ctx)
1228 } else {462 } else {
1229 if ret.Get(0) != nil {463 if ret.Get(0) != nil {
1230- r0 = ret.Get(0).(map[string]*remote.RouteHealth)464+ r0 = ret.Get(0).(*remote.Config)
1231 }465 }
1232 }466 }
1233 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {467 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
1238 return r0, r1472 return r0, r1
1239 }473 }
1240 474
1241-// MockControlPlane_GetHealth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHealth'475+// MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig'
1242-type MockControlPlane_GetHealth_Call struct {476+type MockControlPlane_GetConfig_Call struct {
1243 *mock.Call477 *mock.Call
1244 }478 }
1245 479
1246-// GetHealth is a helper method to define mock.On call480+// GetConfig is a helper method to define mock.On call
1247 // - ctx context.Context481 // - ctx context.Context
1248-func (_e *MockControlPlane_Expecter) GetHealth(ctx any) *MockControlPlane_GetHealth_Call {482+func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call {
1249- return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)}483+ return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}
1250 }484 }
1251 485
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 {
1253 _c.Call.Run(func(args mock.Arguments) {487 _c.Call.Run(func(args mock.Arguments) {
1254 var arg0 context.Context488 var arg0 context.Context
1255 if args[0] != nil {489 if args[0] != nil {
@@ -1262,12 +496,12 @@ func (_c *MockControlPlane_GetHealth_Call) Run(run func(ctx context.Context)) *M
1262 return _c496 return _c
1263 }497 }
1264 498
1265-func (_c *MockControlPlane_GetHealth_Call) Return(stringToRouteHealth map[string]*remote.RouteHealth, err error) *MockControlPlane_GetHealth_Call {499+func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call {
1266- _c.Call.Return(stringToRouteHealth, err)500+ _c.Call.Return(config, err)
1267 return _c501 return _c
1268 }502 }
1269 503
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 {
1271 _c.Call.Return(run)505 _c.Call.Return(run)
1272 return _c506 return _c
1273 }507 }
@@ -1340,74 +574,6 @@ func (_c *MockControlPlane_GetProcessLogs_Call) RunAndReturn(run func(ctx contex
1340 return _c574 return _c
1341 }575 }
1342 576
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-
1411 // GetStatus provides a mock function for the type MockControlPlane577 // GetStatus provides a mock function for the type MockControlPlane
1412 func (_mock *MockControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {578 func (_mock *MockControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
1413 ret := _mock.Called(ctx)579 ret := _mock.Called(ctx)
@@ -1594,92 +760,24 @@ func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx cont
1594 return _c760 return _c
1595 }761 }
1596 762
1597-// ListBackups provides a mock function for the type MockControlPlane763+// ListApps provides a mock function for the type MockControlPlane
1598-func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {764+func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, 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) {
1667 ret := _mock.Called(ctx)765 ret := _mock.Called(ctx)
1668 766
1669 if len(ret) == 0 {767 if len(ret) == 0 {
1670- panic("no return value specified for ListNetworks")768+ panic("no return value specified for ListApps")
1671 }769 }
1672 770
1673- var r0 []*domain.NetworkInfo771+ var r0 []dto.AppSummaryDTO
1674 var r1 error772 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 {
1676 return returnFunc(ctx)774 return returnFunc(ctx)
1677 }775 }
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 {
1679 r0 = returnFunc(ctx)777 r0 = returnFunc(ctx)
1680 } else {778 } else {
1681 if ret.Get(0) != nil {779 if ret.Get(0) != nil {
1682- r0 = ret.Get(0).([]*domain.NetworkInfo)780+ r0 = ret.Get(0).([]dto.AppSummaryDTO)
1683 }781 }
1684 }782 }
1685 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {783 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -1690,18 +788,18 @@ func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.Netw
1690 return r0, r1788 return r0, r1
1691 }789 }
1692 790
1693-// MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'791+// MockControlPlane_ListApps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListApps'
1694-type MockControlPlane_ListNetworks_Call struct {792+type MockControlPlane_ListApps_Call struct {
1695 *mock.Call793 *mock.Call
1696 }794 }
1697 795
1698-// ListNetworks is a helper method to define mock.On call796+// ListApps is a helper method to define mock.On call
1699 // - ctx context.Context797 // - ctx context.Context
1700-func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call {798+func (_e *MockControlPlane_Expecter) ListApps(ctx any) *MockControlPlane_ListApps_Call {
1701- return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}799+ return &MockControlPlane_ListApps_Call{Call: _e.mock.On("ListApps", ctx)}
1702 }800 }
1703 801
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 {
1705 _c.Call.Run(func(args mock.Arguments) {803 _c.Call.Run(func(args mock.Arguments) {
1706 var arg0 context.Context804 var arg0 context.Context
1707 if args[0] != nil {805 if args[0] != nil {
@@ -1714,96 +812,102 @@ func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context))
1714 return _c812 return _c
1715 }813 }
1716 814
1717-func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call {815+func (_c *MockControlPlane_ListApps_Call) Return(appSummaryDTOs []dto.AppSummaryDTO, err error) *MockControlPlane_ListApps_Call {
1718- _c.Call.Return(networkInfos, err)816+ _c.Call.Return(appSummaryDTOs, err)
1719 return _c817 return _c
1720 }818 }
1721 819
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 {
1723 _c.Call.Return(run)821 _c.Call.Return(run)
1724 return _c822 return _c
1725 }823 }
1726 824
1727-// ListOrphanedAttachments provides a mock function for the type MockControlPlane825+// ListBackups provides a mock function for the type MockControlPlane
1728-func (_mock *MockControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {826+func (_mock *MockControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
1729- ret := _mock.Called(ctx)827+ ret := _mock.Called(ctx, app)
1730 828
1731 if len(ret) == 0 {829 if len(ret) == 0 {
1732- panic("no return value specified for ListOrphanedAttachments")830+ panic("no return value specified for ListBackups")
1733 }831 }
1734 832
1735- var r0 []domain.CleanupAttachment833+ var r0 []dto.BackupJob
1736 var r1 error834 var r1 error
1737- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CleanupAttachment, error)); ok {835+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.BackupJob, error)); ok {
1738- return returnFunc(ctx)836+ return returnFunc(ctx, app)
1739 }837 }
1740- if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CleanupAttachment); ok {838+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.BackupJob); ok {
1741- r0 = returnFunc(ctx)839+ r0 = returnFunc(ctx, app)
1742 } else {840 } else {
1743 if ret.Get(0) != nil {841 if ret.Get(0) != nil {
1744- r0 = ret.Get(0).([]domain.CleanupAttachment)842+ r0 = ret.Get(0).([]dto.BackupJob)
1745 }843 }
1746 }844 }
1747- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {845+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1748- r1 = returnFunc(ctx)846+ r1 = returnFunc(ctx, app)
1749 } else {847 } else {
1750 r1 = ret.Error(1)848 r1 = ret.Error(1)
1751 }849 }
1752 return r0, r1850 return r0, r1
1753 }851 }
1754 852
1755-// MockControlPlane_ListOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrphanedAttachments'853+// MockControlPlane_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups'
1756-type MockControlPlane_ListOrphanedAttachments_Call struct {854+type MockControlPlane_ListBackups_Call struct {
1757 *mock.Call855 *mock.Call
1758 }856 }
1759 857
1760-// ListOrphanedAttachments is a helper method to define mock.On call858+// ListBackups is a helper method to define mock.On call
1761 // - ctx context.Context859 // - ctx context.Context
1762-func (_e *MockControlPlane_Expecter) ListOrphanedAttachments(ctx any) *MockControlPlane_ListOrphanedAttachments_Call {860+// - app string
1763- return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}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)}
1764 }863 }
1765 864
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 {
1767 _c.Call.Run(func(args mock.Arguments) {866 _c.Call.Run(func(args mock.Arguments) {
1768 var arg0 context.Context867 var arg0 context.Context
1769 if args[0] != nil {868 if args[0] != nil {
1770 arg0 = args[0].(context.Context)869 arg0 = args[0].(context.Context)
1771 }870 }
871+ var arg1 string
872+ if args[1] != nil {
873+ arg1 = args[1].(string)
874+ }
1772 run(875 run(
1773 arg0,876 arg0,
877+ arg1,
1774 )878 )
1775 })879 })
1776 return _c880 return _c
1777 }881 }
1778 882
1779-func (_c *MockControlPlane_ListOrphanedAttachments_Call) Return(cleanupAttachments []domain.CleanupAttachment, err error) *MockControlPlane_ListOrphanedAttachments_Call {883+func (_c *MockControlPlane_ListBackups_Call) Return(backupJobs []dto.BackupJob, err error) *MockControlPlane_ListBackups_Call {
1780- _c.Call.Return(cleanupAttachments, err)884+ _c.Call.Return(backupJobs, err)
1781 return _c885 return _c
1782 }886 }
1783 887
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 {
1785 _c.Call.Return(run)889 _c.Call.Return(run)
1786 return _c890 return _c
1787 }891 }
1788 892
1789-// ListRoutesWithDetails provides a mock function for the type MockControlPlane893+// ListNetworks provides a mock function for the type MockControlPlane
1790-func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {894+func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1791 ret := _mock.Called(ctx)895 ret := _mock.Called(ctx)
1792 896
1793 if len(ret) == 0 {897 if len(ret) == 0 {
1794- panic("no return value specified for ListRoutesWithDetails")898+ panic("no return value specified for ListNetworks")
1795 }899 }
1796 900
1797- var r0 []remote.RouteInfo901+ var r0 []*domain.NetworkInfo
1798 var r1 error902 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 {
1800 return returnFunc(ctx)904 return returnFunc(ctx)
1801 }905 }
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 {
1803 r0 = returnFunc(ctx)907 r0 = returnFunc(ctx)
1804 } else {908 } else {
1805 if ret.Get(0) != nil {909 if ret.Get(0) != nil {
1806- r0 = ret.Get(0).([]remote.RouteInfo)910+ r0 = ret.Get(0).([]*domain.NetworkInfo)
1807 }911 }
1808 }912 }
1809 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {913 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -1814,18 +918,18 @@ func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]rem
1814 return r0, r1918 return r0, r1
1815 }919 }
1816 920
1817-// MockControlPlane_ListRoutesWithDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRoutesWithDetails'921+// MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'
1818-type MockControlPlane_ListRoutesWithDetails_Call struct {922+type MockControlPlane_ListNetworks_Call struct {
1819 *mock.Call923 *mock.Call
1820 }924 }
1821 925
1822-// ListRoutesWithDetails is a helper method to define mock.On call926+// ListNetworks is a helper method to define mock.On call
1823 // - ctx context.Context927 // - ctx context.Context
1824-func (_e *MockControlPlane_Expecter) ListRoutesWithDetails(ctx any) *MockControlPlane_ListRoutesWithDetails_Call {928+func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call {
1825- return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}929+ return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
1826 }930 }
1827 931
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 {
1829 _c.Call.Run(func(args mock.Arguments) {933 _c.Call.Run(func(args mock.Arguments) {
1830 var arg0 context.Context934 var arg0 context.Context
1831 if args[0] != nil {935 if args[0] != nil {
@@ -1838,22 +942,22 @@ func (_c *MockControlPlane_ListRoutesWithDetails_Call) Run(run func(ctx context.
1838 return _c942 return _c
1839 }943 }
1840 944
1841-func (_c *MockControlPlane_ListRoutesWithDetails_Call) Return(vs []remote.RouteInfo, err error) *MockControlPlane_ListRoutesWithDetails_Call {945+func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call {
1842- _c.Call.Return(vs, err)946+ _c.Call.Return(networkInfos, err)
1843 return _c947 return _c
1844 }948 }
1845 949
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 {
1847 _c.Call.Return(run)951 _c.Call.Return(run)
1848 return _c952 return _c
1849 }953 }
1850 954
1851-// ListSecretsWithAttachments provides a mock function for the type MockControlPlane955+// ListSecrets provides a mock function for the type MockControlPlane
1852-func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {956+func (_mock *MockControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
1853 ret := _mock.Called(ctx, secretDomain)957 ret := _mock.Called(ctx, secretDomain)
1854 958
1855 if len(ret) == 0 {959 if len(ret) == 0 {
1856- panic("no return value specified for ListSecretsWithAttachments")960+ panic("no return value specified for ListSecrets")
1857 }961 }
1858 962
1859 var r0 *remote.SecretsListResult963 var r0 *remote.SecretsListResult
@@ -1876,19 +980,19 @@ func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, s
1876 return r0, r1980 return r0, r1
1877 }981 }
1878 982
1879-// MockControlPlane_ListSecretsWithAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecretsWithAttachments'983+// MockControlPlane_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets'
1880-type MockControlPlane_ListSecretsWithAttachments_Call struct {984+type MockControlPlane_ListSecrets_Call struct {
1881 *mock.Call985 *mock.Call
1882 }986 }
1883 987
1884-// ListSecretsWithAttachments is a helper method to define mock.On call988+// ListSecrets is a helper method to define mock.On call
1885 // - ctx context.Context989 // - ctx context.Context
1886 // - secretDomain string990 // - secretDomain string
1887-func (_e *MockControlPlane_Expecter) ListSecretsWithAttachments(ctx any, secretDomain any) *MockControlPlane_ListSecretsWithAttachments_Call {991+func (_e *MockControlPlane_Expecter) ListSecrets(ctx any, secretDomain any) *MockControlPlane_ListSecrets_Call {
1888- return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)}992+ return &MockControlPlane_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, secretDomain)}
1889 }993 }
1890 994
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 {
1892 _c.Call.Run(func(args mock.Arguments) {996 _c.Call.Run(func(args mock.Arguments) {
1893 var arg0 context.Context997 var arg0 context.Context
1894 if args[0] != nil {998 if args[0] != nil {
@@ -1906,12 +1010,12 @@ func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Run(run func(ctx con
1906 return _c1010 return _c
1907 }1011 }
1908 1012
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 {
1910 _c.Call.Return(secretsListResult, err)1014 _c.Call.Return(secretsListResult, err)
1911 return _c1015 return _c
1912 }1016 }
1913 1017
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 {
1915 _c.Call.Return(run)1019 _c.Call.Return(run)
1916 return _c1020 return _c
1917 }1021 }
@@ -1985,8 +1089,8 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont
1985 }1089 }
1986 1090
1987 // ListVolumeBackups provides a mock function for the type MockControlPlane1091 // ListVolumeBackups provides a mock function for the type MockControlPlane
1988-func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {1092+func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
1989- ret := _mock.Called(ctx, backupDomain)1093+ ret := _mock.Called(ctx, app)
1990 1094
1991 if len(ret) == 0 {1095 if len(ret) == 0 {
1992 panic("no return value specified for ListVolumeBackups")1096 panic("no return value specified for ListVolumeBackups")
@@ -1995,17 +1099,17 @@ func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDoma
1995 var r0 []dto.VolumeBackupJob1099 var r0 []dto.VolumeBackupJob
1996 var r1 error1100 var r1 error
1997 if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok {1101 if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok {
1998- return returnFunc(ctx, backupDomain)1102+ return returnFunc(ctx, app)
1999 }1103 }
2000 if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok {1104 if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok {
2001- r0 = returnFunc(ctx, backupDomain)1105+ r0 = returnFunc(ctx, app)
2002 } else {1106 } else {
2003 if ret.Get(0) != nil {1107 if ret.Get(0) != nil {
2004 r0 = ret.Get(0).([]dto.VolumeBackupJob)1108 r0 = ret.Get(0).([]dto.VolumeBackupJob)
2005 }1109 }
2006 }1110 }
2007 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {1111 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
2008- r1 = returnFunc(ctx, backupDomain)1112+ r1 = returnFunc(ctx, app)
2009 } else {1113 } else {
2010 r1 = ret.Error(1)1114 r1 = ret.Error(1)
2011 }1115 }
@@ -2019,12 +1123,12 @@ type MockControlPlane_ListVolumeBackups_Call struct {
2019 1123
2020 // ListVolumeBackups is a helper method to define mock.On call1124 // ListVolumeBackups is a helper method to define mock.On call
2021 // - ctx context.Context1125 // - ctx context.Context
2022-// - backupDomain string1126+// - app string
2023-func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, backupDomain any) *MockControlPlane_ListVolumeBackups_Call {1127+func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, app any) *MockControlPlane_ListVolumeBackups_Call {
2024- return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}1128+ return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, app)}
2025 }1129 }
2026 1130
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 {
2028 _c.Call.Run(func(args mock.Arguments) {1132 _c.Call.Run(func(args mock.Arguments) {
2029 var arg0 context.Context1133 var arg0 context.Context
2030 if args[0] != nil {1134 if args[0] != nil {
@@ -2047,7 +1151,7 @@ func (_c *MockControlPlane_ListVolumeBackups_Call) Return(volumeBackupJobs []dto
2047 return _c1151 return _c
2048 }1152 }
2049 1153
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 {
2051 _c.Call.Return(run)1155 _c.Call.Return(run)
2052 return _c1156 return _c
2053 }1157 }
@@ -2085,31 +1189,105 @@ type MockControlPlane_ListVolumes_Call struct {
2085 *mock.Call1189 *mock.Call
2086 }1190 }
2087 1191
2088-// ListVolumes is a helper method to define mock.On call1192+// 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
2089 // - ctx context.Context1255 // - ctx context.Context
2090-func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call {1256+// - app string
2091- return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}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)}
2092 }1260 }
2093 1261
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 {
2095 _c.Call.Run(func(args mock.Arguments) {1263 _c.Call.Run(func(args mock.Arguments) {
2096 var arg0 context.Context1264 var arg0 context.Context
2097 if args[0] != nil {1265 if args[0] != nil {
2098 arg0 = args[0].(context.Context)1266 arg0 = args[0].(context.Context)
2099 }1267 }
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+ }
2100 run(1276 run(
2101 arg0,1277 arg0,
1278+ arg1,
1279+ arg2,
2102 )1280 )
2103 })1281 })
2104 return _c1282 return _c
2105 }1283 }
2106 1284
2107-func (_c *MockControlPlane_ListVolumes_Call) Return(volumes []dto.Volume, err error) *MockControlPlane_ListVolumes_Call {1285+func (_c *MockControlPlane_OperationByKey_Call) Return(appDeployResponse *dto.AppDeployResponse, err error) *MockControlPlane_OperationByKey_Call {
2108- _c.Call.Return(volumes, err)1286+ _c.Call.Return(appDeployResponse, err)
2109 return _c1287 return _c
2110 }1288 }
2111 1289
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 {
2113 _c.Call.Return(run)1291 _c.Call.Return(run)
2114 return _c1292 return _c
2115 }1293 }
@@ -2233,156 +1411,53 @@ func (_c *MockControlPlane_Reload_Call) RunAndReturn(run func(ctx context.Contex
2233 return _c1411 return _c
2234 }1412 }
2235 1413
2236-// RemoveAttachment provides a mock function for the type MockControlPlane1414+// RemoveApp provides a mock function for the type MockControlPlane
2237-func (_mock *MockControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {1415+func (_mock *MockControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
2238- ret := _mock.Called(ctx, domainOrGroup, image)1416+ ret := _mock.Called(ctx, app)
2239 1417
2240 if len(ret) == 0 {1418 if len(ret) == 0 {
2241- panic("no return value specified for RemoveAttachment")1419+ panic("no return value specified for RemoveApp")
2242 }1420 }
2243 1421
2244- var r0 error1422+ var r0 *dto.AppDeployResponse
2245- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {1423+ var r1 string
2246- r0 = returnFunc(ctx, domainOrGroup, image)1424+ var r2 error
2247- } else {1425+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok {
2248- r0 = ret.Error(0)1426+ return returnFunc(ctx, app)
2249 }1427 }
2250- return r01428+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok {
2251-}1429+ r0 = returnFunc(ctx, app)
2252-1430+ } else {
2253-// MockControlPlane_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment'1431+ if ret.Get(0) != nil {
2254-type MockControlPlane_RemoveAttachment_Call struct {1432+ r0 = ret.Get(0).(*dto.AppDeployResponse)
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)
2279 }1433 }
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")
2305 }1434 }
2306-1435+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok {
2307- var r0 error1436+ r1 = returnFunc(ctx, app)
2308- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
2309- r0 = returnFunc(ctx, pattern)
2310 } else {1437 } else {
2311- r0 = ret.Error(0)1438+ r1 = ret.Get(1).(string)
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")
2362 }1439 }
2363-1440+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
2364- var r0 error1441+ r2 = returnFunc(ctx, app)
2365- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
2366- r0 = returnFunc(ctx, routeDomain)
2367 } else {1442 } else {
2368- r0 = ret.Error(0)1443+ r2 = ret.Error(2)
2369 }1444 }
2370- return r01445+ return r0, r1, r2
2371 }1446 }
2372 1447
2373-// MockControlPlane_RemoveRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRoute'1448+// MockControlPlane_RemoveApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveApp'
2374-type MockControlPlane_RemoveRoute_Call struct {1449+type MockControlPlane_RemoveApp_Call struct {
2375 *mock.Call1450 *mock.Call
2376 }1451 }
2377 1452
2378-// RemoveRoute is a helper method to define mock.On call1453+// RemoveApp is a helper method to define mock.On call
2379 // - ctx context.Context1454 // - ctx context.Context
2380-// - routeDomain string1455+// - app string
2381-func (_e *MockControlPlane_Expecter) RemoveRoute(ctx any, routeDomain any) *MockControlPlane_RemoveRoute_Call {1456+func (_e *MockControlPlane_Expecter) RemoveApp(ctx any, app any) *MockControlPlane_RemoveApp_Call {
2382- return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)}1457+ return &MockControlPlane_RemoveApp_Call{Call: _e.mock.On("RemoveApp", ctx, app)}
2383 }1458 }
2384 1459
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 {
2386 _c.Call.Run(func(args mock.Arguments) {1461 _c.Call.Run(func(args mock.Arguments) {
2387 var arg0 context.Context1462 var arg0 context.Context
2388 if args[0] != nil {1463 if args[0] != nil {
@@ -2400,58 +1475,64 @@ func (_c *MockControlPlane_RemoveRoute_Call) Run(run func(ctx context.Context, r
2400 return _c1475 return _c
2401 }1476 }
2402 1477
2403-func (_c *MockControlPlane_RemoveRoute_Call) Return(err error) *MockControlPlane_RemoveRoute_Call {1478+func (_c *MockControlPlane_RemoveApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RemoveApp_Call {
2404- _c.Call.Return(err)1479+ _c.Call.Return(appDeployResponse, s, err)
2405 return _c1480 return _c
2406 }1481 }
2407 1482
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 {
2409 _c.Call.Return(run)1484 _c.Call.Return(run)
2410 return _c1485 return _c
2411 }1486 }
2412 1487
2413-// Restart provides a mock function for the type MockControlPlane1488+// RestartApp provides a mock function for the type MockControlPlane
2414-func (_mock *MockControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {1489+func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error) {
2415- ret := _mock.Called(ctx, restartDomain, withAttachments)1490+ ret := _mock.Called(ctx, app, service)
2416 1491
2417 if len(ret) == 0 {1492 if len(ret) == 0 {
2418- panic("no return value specified for Restart")1493+ panic("no return value specified for RestartApp")
2419 }1494 }
2420 1495
2421- var r0 *remote.RestartResult1496+ var r0 *dto.AppDeployResponse
2422- var r1 error1497+ var r1 string
2423- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*remote.RestartResult, error)); ok {1498+ var r2 error
2424- return returnFunc(ctx, restartDomain, withAttachments)1499+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, string, error)); ok {
1500+ return returnFunc(ctx, app, service)
2425 }1501 }
2426- if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *remote.RestartResult); ok {1502+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok {
2427- r0 = returnFunc(ctx, restartDomain, withAttachments)1503+ r0 = returnFunc(ctx, app, service)
2428 } else {1504 } else {
2429 if ret.Get(0) != nil {1505 if ret.Get(0) != nil {
2430- r0 = ret.Get(0).(*remote.RestartResult)1506+ r0 = ret.Get(0).(*dto.AppDeployResponse)
2431 }1507 }
2432 }1508 }
2433- if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {1509+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
2434- r1 = returnFunc(ctx, restartDomain, withAttachments)1510+ r1 = returnFunc(ctx, app, service)
2435 } else {1511 } else {
2436- r1 = ret.Error(1)1512+ r1 = ret.Get(1).(string)
2437 }1513 }
2438- return r0, r11514+ 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
2439 }1520 }
2440 1521
2441-// MockControlPlane_Restart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restart'1522+// MockControlPlane_RestartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartApp'
2442-type MockControlPlane_Restart_Call struct {1523+type MockControlPlane_RestartApp_Call struct {
2443 *mock.Call1524 *mock.Call
2444 }1525 }
2445 1526
2446-// Restart is a helper method to define mock.On call1527+// RestartApp is a helper method to define mock.On call
2447 // - ctx context.Context1528 // - ctx context.Context
2448-// - restartDomain string1529+// - app string
2449-// - withAttachments bool1530+// - service string
2450-func (_e *MockControlPlane_Expecter) Restart(ctx any, restartDomain any, withAttachments any) *MockControlPlane_Restart_Call {1531+func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any) *MockControlPlane_RestartApp_Call {
2451- return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)}1532+ return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service)}
2452 }1533 }
2453 1534
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 {
2455 _c.Call.Run(func(args mock.Arguments) {1536 _c.Call.Run(func(args mock.Arguments) {
2456 var arg0 context.Context1537 var arg0 context.Context
2457 if args[0] != nil {1538 if args[0] != nil {
@@ -2461,9 +1542,9 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta
2461 if args[1] != nil {1542 if args[1] != nil {
2462 arg1 = args[1].(string)1543 arg1 = args[1].(string)
2463 }1544 }
2464- var arg2 bool1545+ var arg2 string
2465 if args[2] != nil {1546 if args[2] != nil {
2466- arg2 = args[2].(bool)1547+ arg2 = args[2].(string)
2467 }1548 }
2468 run(1549 run(
2469 arg0,1550 arg0,
@@ -2474,19 +1555,19 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta
2474 return _c1555 return _c
2475 }1556 }
2476 1557
2477-func (_c *MockControlPlane_Restart_Call) Return(restartResult *remote.RestartResult, err error) *MockControlPlane_Restart_Call {1558+func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RestartApp_Call {
2478- _c.Call.Return(restartResult, err)1559+ _c.Call.Return(appDeployResponse, s, err)
2479 return _c1560 return _c
2480 }1561 }
2481 1562
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 {
2483 _c.Call.Return(run)1564 _c.Call.Return(run)
2484 return _c1565 return _c
2485 }1566 }
2486 1567
2487 // RunBackup provides a mock function for the type MockControlPlane1568 // RunBackup provides a mock function for the type MockControlPlane
2488-func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain string, dbName string) (*dto.BackupRunResponse, error) {1569+func (_mock *MockControlPlane) RunBackup(ctx context.Context, app string, service string, database string) (*dto.BackupRunResponse, error) {
2489- ret := _mock.Called(ctx, backupDomain, dbName)1570+ ret := _mock.Called(ctx, app, service, database)
2490 1571
2491 if len(ret) == 0 {1572 if len(ret) == 0 {
2492 panic("no return value specified for RunBackup")1573 panic("no return value specified for RunBackup")
@@ -2494,18 +1575,18 @@ func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain strin
2494 1575
2495 var r0 *dto.BackupRunResponse1576 var r0 *dto.BackupRunResponse
2496 var r1 error1577 var r1 error
2497- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.BackupRunResponse, error)); ok {1578+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.BackupRunResponse, error)); ok {
2498- return returnFunc(ctx, backupDomain, dbName)1579+ return returnFunc(ctx, app, service, database)
2499 }1580 }
2500- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.BackupRunResponse); ok {1581+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.BackupRunResponse); ok {
2501- r0 = returnFunc(ctx, backupDomain, dbName)1582+ r0 = returnFunc(ctx, app, service, database)
2502 } else {1583 } else {
2503 if ret.Get(0) != nil {1584 if ret.Get(0) != nil {
2504 r0 = ret.Get(0).(*dto.BackupRunResponse)1585 r0 = ret.Get(0).(*dto.BackupRunResponse)
2505 }1586 }
2506 }1587 }
2507- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {1588+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
2508- r1 = returnFunc(ctx, backupDomain, dbName)1589+ r1 = returnFunc(ctx, app, service, database)
2509 } else {1590 } else {
2510 r1 = ret.Error(1)1591 r1 = ret.Error(1)
2511 }1592 }
@@ -2519,13 +1600,14 @@ type MockControlPlane_RunBackup_Call struct {
2519 1600
2520 // RunBackup is a helper method to define mock.On call1601 // RunBackup is a helper method to define mock.On call
2521 // - ctx context.Context1602 // - ctx context.Context
2522-// - backupDomain string1603+// - app string
2523-// - dbName string1604+// - service string
2524-func (_e *MockControlPlane_Expecter) RunBackup(ctx any, backupDomain any, dbName any) *MockControlPlane_RunBackup_Call {1605+// - database string
2525- return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)}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)}
2526 }1608 }
2527 1609
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 {
2529 _c.Call.Run(func(args mock.Arguments) {1611 _c.Call.Run(func(args mock.Arguments) {
2530 var arg0 context.Context1612 var arg0 context.Context
2531 if args[0] != nil {1613 if args[0] != nil {
@@ -2539,10 +1621,15 @@ func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, bac
2539 if args[2] != nil {1621 if args[2] != nil {
2540 arg2 = args[2].(string)1622 arg2 = args[2].(string)
2541 }1623 }
1624+ var arg3 string
1625+ if args[3] != nil {
1626+ arg3 = args[3].(string)
1627+ }
2542 run(1628 run(
2543 arg0,1629 arg0,
2544 arg1,1630 arg1,
2545 arg2,1631 arg2,
1632+ arg3,
2546 )1633 )
2547 })1634 })
2548 return _c1635 return _c
@@ -2553,14 +1640,14 @@ func (_c *MockControlPlane_RunBackup_Call) Return(backupRunResponse *dto.BackupR
2553 return _c1640 return _c
2554 }1641 }
2555 1642
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 {
2557 _c.Call.Return(run)1644 _c.Call.Return(run)
2558 return _c1645 return _c
2559 }1646 }
2560 1647
2561 // RunVolumeBackups provides a mock function for the type MockControlPlane1648 // RunVolumeBackups provides a mock function for the type MockControlPlane
2562-func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error) {1649+func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, app string, service string, volume string) (*dto.VolumeBackupRunResponse, error) {
2563- ret := _mock.Called(ctx, backupDomain, volumeName)1650+ ret := _mock.Called(ctx, app, service, volume)
2564 1651
2565 if len(ret) == 0 {1652 if len(ret) == 0 {
2566 panic("no return value specified for RunVolumeBackups")1653 panic("no return value specified for RunVolumeBackups")
@@ -2568,18 +1655,18 @@ func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomai
2568 1655
2569 var r0 *dto.VolumeBackupRunResponse1656 var r0 *dto.VolumeBackupRunResponse
2570 var r1 error1657 var r1 error
2571- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.VolumeBackupRunResponse, error)); ok {1658+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.VolumeBackupRunResponse, error)); ok {
2572- return returnFunc(ctx, backupDomain, volumeName)1659+ return returnFunc(ctx, app, service, volume)
2573 }1660 }
2574- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.VolumeBackupRunResponse); ok {1661+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.VolumeBackupRunResponse); ok {
2575- r0 = returnFunc(ctx, backupDomain, volumeName)1662+ r0 = returnFunc(ctx, app, service, volume)
2576 } else {1663 } else {
2577 if ret.Get(0) != nil {1664 if ret.Get(0) != nil {
2578 r0 = ret.Get(0).(*dto.VolumeBackupRunResponse)1665 r0 = ret.Get(0).(*dto.VolumeBackupRunResponse)
2579 }1666 }
2580 }1667 }
2581- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {1668+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
2582- r1 = returnFunc(ctx, backupDomain, volumeName)1669+ r1 = returnFunc(ctx, app, service, volume)
2583 } else {1670 } else {
2584 r1 = ret.Error(1)1671 r1 = ret.Error(1)
2585 }1672 }
@@ -2593,13 +1680,14 @@ type MockControlPlane_RunVolumeBackups_Call struct {
2593 1680
2594 // RunVolumeBackups is a helper method to define mock.On call1681 // RunVolumeBackups is a helper method to define mock.On call
2595 // - ctx context.Context1682 // - ctx context.Context
2596-// - backupDomain string1683+// - app string
2597-// - volumeName string1684+// - service string
2598-func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, backupDomain any, volumeName any) *MockControlPlane_RunVolumeBackups_Call {1685+// - volume string
2599- return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}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)}
2600 }1688 }
2601 1689
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 {
2603 _c.Call.Run(func(args mock.Arguments) {1691 _c.Call.Run(func(args mock.Arguments) {
2604 var arg0 context.Context1692 var arg0 context.Context
2605 if args[0] != nil {1693 if args[0] != nil {
@@ -2613,10 +1701,15 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Conte
2613 if args[2] != nil {1701 if args[2] != nil {
2614 arg2 = args[2].(string)1702 arg2 = args[2].(string)
2615 }1703 }
1704+ var arg3 string
1705+ if args[3] != nil {
1706+ arg3 = args[3].(string)
1707+ }
2616 run(1708 run(
2617 arg0,1709 arg0,
2618 arg1,1710 arg1,
2619 arg2,1711 arg2,
1712+ arg3,
2620 )1713 )
2621 })1714 })
2622 return _c1715 return _c
@@ -2627,43 +1720,42 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Return(volumeBackupRunResponse
2627 return _c1720 return _c
2628 }1721 }
2629 1722
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 {
2631 _c.Call.Return(run)1724 _c.Call.Return(run)
2632 return _c1725 return _c
2633 }1726 }
2634 1727
2635-// SetAttachmentSecrets provides a mock function for the type MockControlPlane1728+// SetAppSecrets 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 {1729+func (_mock *MockControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
2637- ret := _mock.Called(ctx, domain1, service, secrets)1730+ ret := _mock.Called(ctx, app, req)
2638 1731
2639 if len(ret) == 0 {1732 if len(ret) == 0 {
2640- panic("no return value specified for SetAttachmentSecrets")1733+ panic("no return value specified for SetAppSecrets")
2641 }1734 }
2642 1735
2643 var r0 error1736 var r0 error
2644- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok {1737+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretSetRequest) error); ok {
2645- r0 = returnFunc(ctx, domain1, service, secrets)1738+ r0 = returnFunc(ctx, app, req)
2646 } else {1739 } else {
2647 r0 = ret.Error(0)1740 r0 = ret.Error(0)
2648 }1741 }
2649 return r01742 return r0
2650 }1743 }
2651 1744
2652-// MockControlPlane_SetAttachmentSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachmentSecrets'1745+// MockControlPlane_SetAppSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAppSecrets'
2653-type MockControlPlane_SetAttachmentSecrets_Call struct {1746+type MockControlPlane_SetAppSecrets_Call struct {
2654 *mock.Call1747 *mock.Call
2655 }1748 }
2656 1749
2657-// SetAttachmentSecrets is a helper method to define mock.On call1750+// SetAppSecrets is a helper method to define mock.On call
2658 // - ctx context.Context1751 // - ctx context.Context
2659-// - domain1 string1752+// - app string
2660-// - service string1753+// - req dto.AppSecretSetRequest
2661-// - secrets map[string]string1754+func (_e *MockControlPlane_Expecter) SetAppSecrets(ctx any, app any, req any) *MockControlPlane_SetAppSecrets_Call {
2662-func (_e *MockControlPlane_Expecter) SetAttachmentSecrets(ctx any, domain1 any, service any, secrets any) *MockControlPlane_SetAttachmentSecrets_Call {1755+ return &MockControlPlane_SetAppSecrets_Call{Call: _e.mock.On("SetAppSecrets", ctx, app, req)}
2663- return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)}
2664 }1756 }
2665 1757
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 {
2667 _c.Call.Run(func(args mock.Arguments) {1759 _c.Call.Run(func(args mock.Arguments) {
2668 var arg0 context.Context1760 var arg0 context.Context
2669 if args[0] != nil {1761 if args[0] != nil {
@@ -2673,30 +1765,25 @@ func (_c *MockControlPlane_SetAttachmentSecrets_Call) Run(run func(ctx context.C
2673 if args[1] != nil {1765 if args[1] != nil {
2674 arg1 = args[1].(string)1766 arg1 = args[1].(string)
2675 }1767 }
2676- var arg2 string1768+ var arg2 dto.AppSecretSetRequest
2677 if args[2] != nil {1769 if args[2] != nil {
2678- arg2 = args[2].(string)1770+ arg2 = args[2].(dto.AppSecretSetRequest)
2679- }
2680- var arg3 map[string]string
2681- if args[3] != nil {
2682- arg3 = args[3].(map[string]string)
2683 }1771 }
2684 run(1772 run(
2685 arg0,1773 arg0,
2686 arg1,1774 arg1,
2687 arg2,1775 arg2,
2688- arg3,
2689 )1776 )
2690 })1777 })
2691 return _c1778 return _c
2692 }1779 }
2693 1780
2694-func (_c *MockControlPlane_SetAttachmentSecrets_Call) Return(err error) *MockControlPlane_SetAttachmentSecrets_Call {1781+func (_c *MockControlPlane_SetAppSecrets_Call) Return(err error) *MockControlPlane_SetAppSecrets_Call {
2695 _c.Call.Return(err)1782 _c.Call.Return(err)
2696 return _c1783 return _c
2697 }1784 }
2698 1785
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 {
2700 _c.Call.Return(run)1787 _c.Call.Return(run)
2701 return _c1788 return _c
2702 }1789 }
@@ -2764,48 +1851,47 @@ func (_c *MockControlPlane_SetSecrets_Call) RunAndReturn(run func(ctx context.Co
2764 return _c1851 return _c
2765 }1852 }
2766 1853
2767-// StreamContainerLogs provides a mock function for the type MockControlPlane1854+// ShowApp provides a mock function for the type MockControlPlane
2768-func (_mock *MockControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {1855+func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
2769- ret := _mock.Called(ctx, logDomain, lines)1856+ ret := _mock.Called(ctx, app)
2770 1857
2771 if len(ret) == 0 {1858 if len(ret) == 0 {
2772- panic("no return value specified for StreamContainerLogs")1859+ panic("no return value specified for ShowApp")
2773 }1860 }
2774 1861
2775- var r0 <-chan string1862+ var r0 *dto.AppShowResponse
2776 var r1 error1863 var r1 error
2777- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (<-chan string, error)); ok {1864+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppShowResponse, error)); ok {
2778- return returnFunc(ctx, logDomain, lines)1865+ return returnFunc(ctx, app)
2779 }1866 }
2780- if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) <-chan string); ok {1867+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppShowResponse); ok {
2781- r0 = returnFunc(ctx, logDomain, lines)1868+ r0 = returnFunc(ctx, app)
2782 } else {1869 } else {
2783 if ret.Get(0) != nil {1870 if ret.Get(0) != nil {
2784- r0 = ret.Get(0).(<-chan string)1871+ r0 = ret.Get(0).(*dto.AppShowResponse)
2785 }1872 }
2786 }1873 }
2787- if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {1874+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
2788- r1 = returnFunc(ctx, logDomain, lines)1875+ r1 = returnFunc(ctx, app)
2789 } else {1876 } else {
2790 r1 = ret.Error(1)1877 r1 = ret.Error(1)
2791 }1878 }
2792 return r0, r11879 return r0, r1
2793 }1880 }
2794 1881
2795-// MockControlPlane_StreamContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamContainerLogs'1882+// MockControlPlane_ShowApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShowApp'
2796-type MockControlPlane_StreamContainerLogs_Call struct {1883+type MockControlPlane_ShowApp_Call struct {
2797 *mock.Call1884 *mock.Call
2798 }1885 }
2799 1886
2800-// StreamContainerLogs is a helper method to define mock.On call1887+// ShowApp is a helper method to define mock.On call
2801 // - ctx context.Context1888 // - ctx context.Context
2802-// - logDomain string1889+// - app string
2803-// - lines int1890+func (_e *MockControlPlane_Expecter) ShowApp(ctx any, app any) *MockControlPlane_ShowApp_Call {
2804-func (_e *MockControlPlane_Expecter) StreamContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_StreamContainerLogs_Call {1891+ return &MockControlPlane_ShowApp_Call{Call: _e.mock.On("ShowApp", ctx, app)}
2805- return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)}
2806 }1892 }
2807 1893
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 {
2809 _c.Call.Run(func(args mock.Arguments) {1895 _c.Call.Run(func(args mock.Arguments) {
2810 var arg0 context.Context1896 var arg0 context.Context
2811 if args[0] != nil {1897 if args[0] != nil {
@@ -2815,78 +1901,153 @@ func (_c *MockControlPlane_StreamContainerLogs_Call) Run(run func(ctx context.Co
2815 if args[1] != nil {1901 if args[1] != nil {
2816 arg1 = args[1].(string)1902 arg1 = args[1].(string)
2817 }1903 }
2818- var arg2 int1904+ run(
2819- if args[2] != nil {1905+ arg0,
2820- arg2 = args[2].(int)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)
2821 }1977 }
2822 run(1978 run(
2823 arg0,1979 arg0,
2824 arg1,1980 arg1,
2825- arg2,
2826 )1981 )
2827 })1982 })
2828 return _c1983 return _c
2829 }1984 }
2830 1985
2831-func (_c *MockControlPlane_StreamContainerLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamContainerLogs_Call {1986+func (_c *MockControlPlane_StartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StartApp_Call {
2832- _c.Call.Return(stringCh, err)1987+ _c.Call.Return(appDeployResponse, s, err)
2833 return _c1988 return _c
2834 }1989 }
2835 1990
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 {
2837 _c.Call.Return(run)1992 _c.Call.Return(run)
2838 return _c1993 return _c
2839 }1994 }
2840 1995
2841-// StreamProcessLogs provides a mock function for the type MockControlPlane1996+// StopApp provides a mock function for the type MockControlPlane
2842-func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {1997+func (_mock *MockControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
2843- ret := _mock.Called(ctx, lines)1998+ ret := _mock.Called(ctx, app)
2844 1999
2845 if len(ret) == 0 {2000 if len(ret) == 0 {
2846- panic("no return value specified for StreamProcessLogs")2001+ panic("no return value specified for StopApp")
2847 }2002 }
2848 2003
2849- var r0 <-chan string2004+ var r0 *dto.AppDeployResponse
2850- var r1 error2005+ var r1 string
2851- if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok {2006+ var r2 error
2852- return returnFunc(ctx, lines)2007+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok {
2008+ return returnFunc(ctx, app)
2853 }2009 }
2854- if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok {2010+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok {
2855- r0 = returnFunc(ctx, lines)2011+ r0 = returnFunc(ctx, app)
2856 } else {2012 } else {
2857 if ret.Get(0) != nil {2013 if ret.Get(0) != nil {
2858- r0 = ret.Get(0).(<-chan string)2014+ r0 = ret.Get(0).(*dto.AppDeployResponse)
2859 }2015 }
2860 }2016 }
2861- if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {2017+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok {
2862- r1 = returnFunc(ctx, lines)2018+ r1 = returnFunc(ctx, app)
2863 } else {2019 } else {
2864- r1 = ret.Error(1)2020+ r1 = ret.Get(1).(string)
2865 }2021 }
2866- return r0, r12022+ 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
2867 }2028 }
2868 2029
2869-// MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs'2030+// MockControlPlane_StopApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopApp'
2870-type MockControlPlane_StreamProcessLogs_Call struct {2031+type MockControlPlane_StopApp_Call struct {
2871 *mock.Call2032 *mock.Call
2872 }2033 }
2873 2034
2874-// StreamProcessLogs is a helper method to define mock.On call2035+// StopApp is a helper method to define mock.On call
2875 // - ctx context.Context2036 // - ctx context.Context
2876-// - lines int2037+// - app string
2877-func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call {2038+func (_e *MockControlPlane_Expecter) StopApp(ctx any, app any) *MockControlPlane_StopApp_Call {
2878- return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}2039+ return &MockControlPlane_StopApp_Call{Call: _e.mock.On("StopApp", ctx, app)}
2879 }2040 }
2880 2041
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 {
2882 _c.Call.Run(func(args mock.Arguments) {2043 _c.Call.Run(func(args mock.Arguments) {
2883 var arg0 context.Context2044 var arg0 context.Context
2884 if args[0] != nil {2045 if args[0] != nil {
2885 arg0 = args[0].(context.Context)2046 arg0 = args[0].(context.Context)
2886 }2047 }
2887- var arg1 int2048+ var arg1 string
2888 if args[1] != nil {2049 if args[1] != nil {
2889- arg1 = args[1].(int)2050+ arg1 = args[1].(string)
2890 }2051 }
2891 run(2052 run(
2892 arg0,2053 arg0,
@@ -2896,54 +2057,65 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Cont
2896 return _c2057 return _c
2897 }2058 }
2898 2059
2899-func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call {2060+func (_c *MockControlPlane_StopApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StopApp_Call {
2900- _c.Call.Return(stringCh, err)2061+ _c.Call.Return(appDeployResponse, s, err)
2901 return _c2062 return _c
2902 }2063 }
2903 2064
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 {
2905 _c.Call.Return(run)2066 _c.Call.Return(run)
2906 return _c2067 return _c
2907 }2068 }
2908 2069
2909-// UpdateRoute provides a mock function for the type MockControlPlane2070+// StreamProcessLogs provides a mock function for the type MockControlPlane
2910-func (_mock *MockControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {2071+func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
2911- ret := _mock.Called(ctx, route)2072+ ret := _mock.Called(ctx, lines)
2912 2073
2913 if len(ret) == 0 {2074 if len(ret) == 0 {
2914- panic("no return value specified for UpdateRoute")2075+ panic("no return value specified for StreamProcessLogs")
2915 }2076 }
2916 2077
2917- var r0 error2078+ var r0 <-chan string
2918- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {2079+ var r1 error
2919- r0 = returnFunc(ctx, route)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)
2920 } else {2085 } else {
2921- r0 = ret.Error(0)2086+ if ret.Get(0) != nil {
2087+ r0 = ret.Get(0).(<-chan string)
2088+ }
2922 }2089 }
2923- return r02090+ 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
2924 }2096 }
2925 2097
2926-// MockControlPlane_UpdateRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRoute'2098+// MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs'
2927-type MockControlPlane_UpdateRoute_Call struct {2099+type MockControlPlane_StreamProcessLogs_Call struct {
2928 *mock.Call2100 *mock.Call
2929 }2101 }
2930 2102
2931-// UpdateRoute is a helper method to define mock.On call2103+// StreamProcessLogs is a helper method to define mock.On call
2932 // - ctx context.Context2104 // - ctx context.Context
2933-// - route domain.Route2105+// - lines int
2934-func (_e *MockControlPlane_Expecter) UpdateRoute(ctx any, route any) *MockControlPlane_UpdateRoute_Call {2106+func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call {
2935- return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}2107+ return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}
2936 }2108 }
2937 2109
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 {
2939 _c.Call.Run(func(args mock.Arguments) {2111 _c.Call.Run(func(args mock.Arguments) {
2940 var arg0 context.Context2112 var arg0 context.Context
2941 if args[0] != nil {2113 if args[0] != nil {
2942 arg0 = args[0].(context.Context)2114 arg0 = args[0].(context.Context)
2943 }2115 }
2944- var arg1 domain.Route2116+ var arg1 int
2945 if args[1] != nil {2117 if args[1] != nil {
2946- arg1 = args[1].(domain.Route)2118+ arg1 = args[1].(int)
2947 }2119 }
2948 run(2120 run(
2949 arg0,2121 arg0,
@@ -2953,12 +2125,12 @@ func (_c *MockControlPlane_UpdateRoute_Call) Run(run func(ctx context.Context, r
2953 return _c2125 return _c
2954 }2126 }
2955 2127
2956-func (_c *MockControlPlane_UpdateRoute_Call) Return(err error) *MockControlPlane_UpdateRoute_Call {2128+func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call {
2957- _c.Call.Return(err)2129+ _c.Call.Return(stringCh, err)
2958 return _c2130 return _c
2959 }2131 }
2960 2132
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 {
2962 _c.Call.Return(run)2134 _c.Call.Return(run)
2963 return _c2135 return _c
2964 }2136 }
M internal/adapters/in/cli/networks.go +1 -1
@@ -37,7 +37,7 @@ Examples:
37 gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,37 gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
38 RunE: func(cmd *cobra.Command, _ []string) error {38 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()39 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)40+ handle, err := resolveControlPlane(cliConfigPath)
41 if err != nil {41 if err != nil {
42 return err42 return err
43 }43 }
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 {
63 func cliRenderInfo(msg string) string {63 func cliRenderInfo(msg string) string {
64 return styles.RenderInfo(msg)64 return styles.RenderInfo(msg)
65 }65 }
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 (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io"8 "io"
9- "net/http"
10 "os"9 "os"
11 "os/exec"10 "os/exec"
12 "path/filepath"11 "path/filepath"
@@ -14,11 +13,9 @@ import (
14 "strings"13 "strings"
15 "time"14 "time"
16 15
17- tea "github.com/charmbracelet/bubbletea"
18 "github.com/spf13/cobra"16 "github.com/spf13/cobra"
19 17
20 "github.com/bnema/gordon/internal/adapters/in/cli/remote"18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
22 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
23 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
24 "github.com/bnema/gordon/pkg/validation"21 "github.com/bnema/gordon/pkg/validation"
@@ -38,6 +35,7 @@ type buildConfig struct {
38 type imagePush struct {35 type imagePush struct {
39 Registry string36 Registry string
40 ImageName string37 ImageName string
38+ SourceRef string
41 Version string39 Version string
42 VersionRef string40 VersionRef string
43 LatestRef string41 LatestRef string
@@ -45,47 +43,32 @@ type imagePush struct {
45 43
46 // pushRequest holds all inputs for the push command.44 // pushRequest holds all inputs for the push command.
47 type pushRequest struct {45 type pushRequest struct {
48- ImageArg string46+ ImageArg string
49- Domain string47+ Tag string
50- Tag string48+ Build buildConfig
51- Build buildConfig
52- NoDeploy bool
53- NoConfirm bool
54 }49 }
55 50
56 func newPushCmd() *cobra.Command {51 func newPushCmd() *cobra.Command {
57 var (52 var (
58- noDeploy bool
59- noConfirm bool
60 build bool53 build bool
61 platform string54 platform string
62 tag string55 tag string
63 dockerfile string56 dockerfile string
64 buildArgs []string57 buildArgs []string
65- domainFlag string
66 )58 )
67 59
68 cmd := &cobra.Command{60 cmd := &cobra.Command{
69 Use: "push [image]",61 Use: "push [image]",
70- Short: "Tag, push, and optionally deploy an image",62+ Short: "Tag and push an image to the Gordon registry",
71 Long: `Tags a local image for the Gordon registry and pushes it.63 Long: `Tags a local image for the Gordon registry and pushes it.
72-Uses git tags for versioning. Optionally triggers deployment after push.64+Uses git tags for versioning. Push transfers OCI content only: it never
73-65+deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
74-Image-first syntax is primary:66+the manifest that references the pushed tag.
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.
83 67
84 Examples:68 Examples:
85 gordon push myapp --build --remote ...69 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 ...
87 gordon push registry.example.com/myapp:v1.2.3 --build --remote ...71 gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
88- gordon push --domain myapp.example.com --build --remote ...
89 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,72 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
90 Args: cobra.MaximumNArgs(1),73 Args: cobra.MaximumNArgs(1),
91 RunE: func(cmd *cobra.Command, args []string) error {74 RunE: func(cmd *cobra.Command, args []string) error {
@@ -94,24 +77,18 @@ Examples:
94 imageArg = args[0]77 imageArg = args[0]
95 }78 }
96 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{79 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
97- ImageArg: imageArg,80+ ImageArg: imageArg,
98- Domain: domainFlag,81+ Tag: tag,
99- Tag: tag,82+ Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
100- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
101- NoDeploy: noDeploy,
102- NoConfirm: noConfirm,
103 })83 })
104 },84 },
105 }85 }
106 86
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")
109 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")87 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
110 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")88 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
111 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")89 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
112 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")90 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
113 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")91 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
114- cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
115 92
116 return cmd93 return cmd
117 }94 }
@@ -141,7 +118,8 @@ const (
141 118
142 type classifiedPushArg struct {119 type classifiedPushArg struct {
143 kind pushArgKind120 kind pushArgKind
144- lookupImage string121+ sourceRef string
122+ repository string
145 legacyDomain string123 legacyDomain string
146 }124 }
147 125
@@ -150,11 +128,11 @@ func classifyPushArgument(arg string) classifiedPushArg {
150 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}128 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
151 }129 }
152 130
153- classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}131+ _, repository, _ := parseImageRef(arg)
154- if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {132+ if repository == "" {
155- classified.lookupImage = parsedImage133+ repository, _ = validation.ParseImageReference(arg)
156 }134 }
157- return classified135+ return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
158 }136 }
159 137
160 func looksLikeLegacyDomain(arg string) bool {138 func looksLikeLegacyDomain(arg string) bool {
@@ -168,51 +146,29 @@ func looksLikeLegacyDomain(arg string) bool {
168 return strings.Contains(host, ".")146 return strings.Contains(host, ".")
169 }147 }
170 148
171-// resolveRoute determines the registry, image name, and domain from the input mode.149+// resolveImageTarget determines the registry and image name from the input.
172-func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {150+// Push transfers OCI content only and never triggers deploys or route
173- if domainFlag != "" {151+// lookups. The deploy target comes from the app manifest applied
174- return resolveFromDomain(ctx, cp, domainFlag)152+// separately via `gordon apps apply`.
175- }153+func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
176-
177 if imageArg == "" {154 if imageArg == "" {
178 imageArg, err = detectImageName(dockerfile)155 imageArg, err = detectImageName(dockerfile)
179 if err != nil {156 if err != nil {
180 return "", "", "", err157 return "", "", "", err
181 }158 }
182- fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))159+ if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
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) {
200 return "", "", "", err160 return "", "", "", err
201 }161 }
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
211 }162 }
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+ }
213 classified := classifyPushArgument(imageArg)166 classified := classifyPushArgument(imageArg)
214-167+ registry, imageName, _ = parseImageRef(classified.sourceRef)
215- return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)168+ if registry == "" || imageName == "" {
169+ return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
170+ }
171+ return registry, imageName, classified.sourceRef, nil
216 }172 }
217 173
218 // resolveVersion determines and validates the version tag.174 // resolveVersion determines and validates the version tag.
@@ -233,7 +189,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
233 }189 }
234 defer handle.close()190 defer handle.close()
235 191
236- registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)192+ registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
237 if err != nil {193 if err != nil {
238 return err194 return err
239 }195 }
@@ -246,7 +202,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
246 return err202 return err
247 }203 }
248 204
249- img := imagePush{Registry: registry, ImageName: imageName, Version: version}205+ img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
250 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)206 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
251 207
252 imageOps, err := newPushImageOps(inferredRemote)208 imageOps, err := newPushImageOps(inferredRemote)
@@ -262,22 +218,14 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
262 return err218 return err
263 }219 }
264 }220 }
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)
270 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}221 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 {
272 return err223 return err
273 }224 }
274 225
275 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {226 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276 return err227 return err
277 }228 }
278- if !skipExplicitDeploy {
279- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280- }
281 return nil229 return nil
282 }230 }
283 231
@@ -286,7 +234,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
286 if err != nil {234 if err != nil {
287 return "", nil, nil, err235 return "", nil, nil, err
288 }236 }
289- inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)237+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
290 if err != nil {238 if err != nil {
291 return "", nil, nil, err239 return "", nil, nil, err
292 }240 }
@@ -297,7 +245,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
297 }245 }
298 return dockerfile, inferredRemote, handle, nil246 return dockerfile, inferredRemote, handle, nil
299 }247 }
300- handle, err = resolveControlPlane(configPath)248+ handle, err = resolveControlPlane(cliConfigPath)
301 if err != nil {249 if err != nil {
302 return "", nil, nil, err250 return "", nil, nil, err
303 }251 }
@@ -320,179 +268,15 @@ func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error
320 return newImageOpsFn()268 return newImageOpsFn()
321 }269 }
322 270
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 {
324 if build.Enabled {272 if build.Enabled {
325- return buildAndPush(ctx, imageOps, build, img)273+ return buildAndPush(ctx, out, 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)
477 }274 }
478- if selected == "" {275+ return tagAndPush(ctx, out, imageOps, img)
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")
490 }276 }
491 277
492-// detectImageName auto-detects the image name from context.278+// detectImageName resolves the image name from Dockerfile labels or the
493-// Resolution order:279+// current directory name.
494-// 1. Dockerfile label gordon.domain (if Dockerfile exists)
495-// 2. Current directory name
496 func detectImageName(dockerfile string) (string, error) {280 func detectImageName(dockerfile string) (string, error) {
497 // Try Dockerfile labels281 // Try Dockerfile labels
498 labels := parseDockerfileLabels(dockerfile)282 labels := parseDockerfileLabels(dockerfile)
@@ -513,7 +297,7 @@ func detectImageName(dockerfile string) (string, error) {
513 297
514 dirName := filepath.Base(cwd)298 dirName := filepath.Base(cwd)
515 if dirName == "." || dirName == "/" {299 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")
517 }301 }
518 302
519 return dirName, nil303 return dirName, nil
@@ -655,7 +439,7 @@ func parseTagRef(ref string) string {
655 return tag439 return tag
656 }440 }
657 441
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 {
659 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {443 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
660 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)444 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
661 }445 }
@@ -663,12 +447,16 @@ func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img
663 // Build and load into local daemon (NOT --push).447 // Build and load into local daemon (NOT --push).
664 // The native registry push client handles chunked uploads to stay448 // The native registry push client handles chunked uploads to stay
665 // within Cloudflare's 100MB per-request limit.449 // 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+ }
667 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {453 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
668 return err454 return err
669 }455 }
670 456
671- fmt.Println("Pushing...")457+ if err := cliWriteLine(out, "Pushing..."); err != nil {
458+ return err
459+ }
672 if err := ops.Push(ctx, img.LatestRef); err != nil {460 if err := ops.Push(ctx, img.LatestRef); err != nil {
673 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)461 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
674 }462 }
@@ -731,10 +519,15 @@ func buildImageArgs(ctx context.Context, version, platform, dockerfile string, b
731 return args519 return args
732 }520 }
733 521
734-func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {522+func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
735- localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)523+ localImage := img.SourceRef
524+ if localImage == "" {
525+ return errors.New("push source image reference cannot be empty")
526+ }
736 527
737- fmt.Println("\nChecking local image...")528+ if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
529+ return err
530+ }
738 exists, err := ops.Exists(ctx, localImage)531 exists, err := ops.Exists(ctx, localImage)
739 if err != nil {532 if err != nil {
740 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)533 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 {
743 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)536 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
744 }537 }
745 538
746- fmt.Println("Tagging...")539+ if err := cliWriteLine(out, "Tagging..."); err != nil {
540+ return err
541+ }
747 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {542 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
748 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)543 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
749 }544 }
@@ -753,7 +548,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
753 }548 }
754 }549 }
755 550
756- fmt.Println("Pushing...")551+ if err := cliWriteLine(out, "Pushing..."); err != nil {
552+ return err
553+ }
757 if err := ops.Push(ctx, img.VersionRef); err != nil {554 if err := ops.Push(ctx, img.VersionRef); err != nil {
758 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)555 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
759 }556 }
@@ -765,118 +562,6 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
765 return nil562 return nil
766 }563 }
767 564
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-
880 func isInteractiveTerminal() bool {565 func isInteractiveTerminal() bool {
881 term := os.Getenv("TERM")566 term := os.Getenv("TERM")
882 if term == "" || term == "dumb" {567 if term == "" || term == "dumb" {
@@ -898,6 +583,9 @@ func parseImageRef(image string) (registry, name, tag string) {
898 }583 }
899 registry = parts[0]584 registry = parts[0]
900 nameTag := parts[1]585 nameTag := parts[1]
586+ if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
587+ return registry, nameTag[:idx], nameTag[idx+1:]
588+ }
901 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {589 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
902 name = nameTag[:idx]590 name = nameTag[:idx]
903 tag = nameTag[idx+1:]591 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 {
35 type dockerImageOps struct {35 type dockerImageOps struct {
36 cli client.APIClient36 cli client.APIClient
37 insecureTLS bool37 insecureTLS bool
38+ plainHTTP bool
38 progress io.Writer39 progress io.Writer
39 remoteClient *remote.Client // nil when local40 remoteClient *remote.Client // nil when local
40 }41 }
@@ -70,8 +71,11 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
70 if err != nil {71 if err != nil {
71 return nil, err72 return nil, err
72 }73 }
73- if resolved != nil && resolved.Token != "" {74+ if resolved != nil {
74- ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)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+ }
75 }79 }
76 return ops, nil80 return ops, nil
77 }81 }
@@ -87,6 +91,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
87 opts := []registrypush.Option{91 opts := []registrypush.Option{
88 registrypush.WithProgress(d.progress),92 registrypush.WithProgress(d.progress),
89 registrypush.WithInsecureTLS(d.insecureTLS),93 registrypush.WithInsecureTLS(d.insecureTLS),
94+ registrypush.WithPlainHTTP(d.plainHTTP),
90 }95 }
91 96
92 if d.remoteClient != nil {97 if d.remoteClient != nil {
@@ -107,6 +112,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
107 retryOpts := []registrypush.Option{112 retryOpts := []registrypush.Option{
108 registrypush.WithProgress(d.progress),113 registrypush.WithProgress(d.progress),
109 registrypush.WithInsecureTLS(d.insecureTLS),114 registrypush.WithInsecureTLS(d.insecureTLS),
115+ registrypush.WithPlainHTTP(d.plainHTTP),
110 registrypush.WithAuth(authHeader),116 registrypush.WithAuth(authHeader),
111 }117 }
112 pusher = registrypush.New(retryOpts...)118 pusher = registrypush.New(retryOpts...)
M internal/adapters/in/cli/push_integration_test.go +8 -6
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
8 "strings"9 "strings"
@@ -33,9 +34,13 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
33 img, err := random.Image(512, 1)34 img, err := random.Image(512, 1)
34 require.NoError(t, err)35 require.NoError(t, err)
35 36
37+ registry := strings.TrimPrefix(harness.server.URL, "http://")
38+ versionRef := registry + "/testapp:v1.0.0"
39+ latestRef := registry + "/testapp:latest"
40+
36 ops := climocks.NewMockpushImageOps(t)41 ops := climocks.NewMockpushImageOps(t)
37 ops.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)42 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)
39 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {44 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {
40 pusher := registrypush.New(45 pusher := registrypush.New(
41 registrypush.WithChunkSize(256),46 registrypush.WithChunkSize(256),
@@ -47,13 +52,10 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
47 return pusher.Push(ctx, ref)52 return pusher.Push(ctx, ref)
48 })53 })
49 54
50- registry := strings.TrimPrefix(harness.server.URL, "http://")55+ err = tagAndPush(ctx, io.Discard, ops, imagePush{
51- versionRef := registry + "/testapp:v1.0.0"
52- latestRef := registry + "/testapp:latest"
53-
54- err = tagAndPush(ctx, ops, imagePush{
55 Registry: registry,56 Registry: registry,
56 ImageName: "testapp",57 ImageName: "testapp",
58+ SourceRef: versionRef,
57 Version: "v1.0.0",59 Version: "v1.0.0",
58 VersionRef: versionRef,60 VersionRef: versionRef,
59 LatestRef: latestRef,61 LatestRef: latestRef,
M internal/adapters/in/cli/push_remote_inference_test.go +19 -414
@@ -13,16 +13,14 @@ import (
13 13
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16-
17- "github.com/bnema/gordon/internal/domain"
18 )16 )
19 17
20 func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {18 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) {
22 assert.Equal(t, "myapp", image)20 assert.Equal(t, "myapp", image)
23- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK21+ return []string{"latest"}, http.StatusOK
24 })22 })
25- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {23+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
26 assert.Equal(t, "myapp", image)24 assert.Equal(t, "myapp", image)
27 return nil, http.StatusOK25 return nil, http.StatusOK
28 })26 })
@@ -43,11 +41,11 @@ url = "`+staging.URL+`"
43 }41 }
44 42
45 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {43 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {44+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
47- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK45+ return []string{"latest"}, http.StatusOK
48 })46 })
49- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {47+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
50- return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK48+ return []string{"latest"}, http.StatusOK
51 })49 })
52 50
53 configurePushRemoteInferenceTestEnv(t, `51 configurePushRemoteInferenceTestEnv(t, `
@@ -73,9 +71,9 @@ func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
73 })71 })
74 72
75 called := false73 called := false
76- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {74+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
77 called = true75 called = true
78- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK76+ return []string{"latest"}, http.StatusOK
79 })77 })
80 78
81 configurePushRemoteInferenceTestEnv(t, `79 configurePushRemoteInferenceTestEnv(t, `
@@ -93,9 +91,9 @@ url = "`+prod.URL+`"
93 91
94 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {92 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95 called := false93 called := false
96- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {94+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
97 called = true95 called = true
98- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK96+ return []string{"latest"}, http.StatusOK
99 })97 })
100 98
101 configurePushRemoteInferenceTestEnv(t, `99 configurePushRemoteInferenceTestEnv(t, `
@@ -111,26 +109,11 @@ url = "`+prod.URL+`"
111 assert.False(t, called)109 assert.False(t, called)
112 }110 }
113 111
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-
129 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {112 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {113+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
131- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK114+ return []string{"latest"}, http.StatusOK
132 })115 })
133- broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {116+ broken := newTagsTestServer(t, func(image string) ([]string, int) {
134 return nil, http.StatusInternalServerError117 return nil, http.StatusInternalServerError
135 })118 })
136 119
@@ -149,199 +132,6 @@ url = "`+broken.URL+`"
149 assert.Contains(t, err.Error(), "broken")132 assert.Contains(t, err.Error(), "broken")
150 }133 }
151 134
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-
345 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {135 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346 prod := newTagsTestServer(t, func(repository string) ([]string, int) {136 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347 return []string{"latest", "v1.0.0"}, http.StatusOK137 return []string{"latest", "v1.0.0"}, http.StatusOK
@@ -386,205 +176,20 @@ func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
386 176
387 configHome := t.TempDir()177 configHome := t.TempDir()
388 t.Setenv("XDG_CONFIG_HOME", configHome)178 t.Setenv("XDG_CONFIG_HOME", configHome)
389- configPath := filepath.Join(configHome, "gordon", "remotes.toml")179+ cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml")
390- require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))180+ require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755))
391- require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))181+ require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392 }182 }
393 183
394-func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {184+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, 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 {
444 t.Helper()185 t.Helper()
445-
446 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {186 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447- switch {187+ handleListTagsProbe(t, w, r, handler)
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- }
461 }))188 }))
462 t.Cleanup(server.Close)189 t.Cleanup(server.Close)
463 return server190 return server
464 }191 }
465 192
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-
588 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {193 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589 t.Helper()194 t.Helper()
590 if handler == nil {195 if handler == nil {
M internal/adapters/in/cli/push_test.go +5 -110
@@ -2,8 +2,6 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6- "fmt"
7 "io"5 "io"
8 "os"6 "os"
9 "os/exec"7 "os/exec"
@@ -11,11 +9,7 @@ import (
11 "strings"9 "strings"
12 "testing"10 "testing"
13 11
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"
17 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
18- "github.com/stretchr/testify/mock"
19 )13 )
20 14
21 func TestValidateBuildArg(t *testing.T) {15 func TestValidateBuildArg(t *testing.T) {
@@ -79,12 +73,12 @@ func TestClassifyPushArgument(t *testing.T) {
79 arg string73 arg string
80 want classifiedPushArg74 want classifiedPushArg
81 }{75 }{
82- {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},76+ {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:latest", repository: "myapp"}},
83- {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},77+ {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:v1.2.3", repository: "myapp"}},
84- {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/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"}},
85- {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp@sha256:deadbeef"}},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"}},
86 {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},80 {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"}},
88 }82 }
89 83
90 for _, tt := range tests {84 for _, tt := range tests {
@@ -449,102 +443,3 @@ func TestParseLabelPair(t *testing.T) {
449 })443 })
450 }444 }
451 }445 }
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
478 return nil, fmt.Errorf("request failed after retries")478 return nil, fmt.Errorf("request failed after retries")
479 }479 }
480 480
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-
660 // Secrets API481 // Secrets API
661 482
662-// AttachmentSecrets represents secrets for an attachment container.483+// SecretsListResult contains domain secret keys.
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.
669 type SecretsListResult struct {484 type SecretsListResult struct {
670- Domain string `json:"domain"`485+ Domain string `json:"domain"`
671- Keys []string `json:"keys"`486+ Keys []string `json:"keys"`
672- Attachments []AttachmentSecrets `json:"attachments,omitempty"`
673 }487 }
674 488
675-// ListSecrets returns the list of secret keys for a domain.489+// ListSecretsWithAttachments returns domain secret keys. Attachment
676-func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {490+// containers are retired: the daemon answers without attachment keys.
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.
685 func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {491 func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
686 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)492 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
687 if err != nil {493 if err != nil {
@@ -714,40 +520,18 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err
714 return parseResponse(resp, nil)520 return parseResponse(resp, nil)
715 }521 }
716 522
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-
737 // Status API523 // Status API
738 524
739 // Status represents the Gordon server status.525 // Status represents the Gordon server status.
740 type Status struct {526 type Status struct {
741- Routes int `json:"routes"`527+ Apps int `json:"apps"`
742 RegistryDomain string `json:"registry_domain"`528 RegistryDomain string `json:"registry_domain"`
743 RegistryPort int `json:"registry_port"`529 RegistryPort int `json:"registry_port"`
744 ServerPort int `json:"server_port"`530 ServerPort int `json:"server_port"`
745- AutoRoute bool `json:"auto_route"`
746 NetworkIsolation bool `json:"network_isolation"`531 NetworkIsolation bool `json:"network_isolation"`
747 ContainerStatus map[string]string `json:"container_status"`532 ContainerStatus map[string]string `json:"container_status"`
748 }533 }
749 534
750-// GetTLSStatus returns the public TLS/ACME status.
751 func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {535 func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
752 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)536 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
753 if err != nil {537 if err != nil {
@@ -840,10 +624,10 @@ func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*d
840 }624 }
841 625
842 // ListBackups returns backups globally or for a domain.626 // 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) {
844 path := "/backups"628 path := "/backups"
845- if backupDomain != "" {629+ if app != "" {
846- path += "/" + url.PathEscape(backupDomain)630+ path += "/" + url.PathEscape(app)
847 }631 }
848 632
849 resp, err := c.request(ctx, http.MethodGet, path, nil)633 resp, err := c.request(ctx, http.MethodGet, path, nil)
@@ -859,7 +643,8 @@ func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.Ba
859 return result.Backups, nil643 return result.Backups, nil
860 }644 }
861 645
862-// BackupStatus returns aggregate backup status.646+// BackupStatus returns stored backups plus declared targets that have no
647+// completed backup yet.
863 func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {648 func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
864 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)649 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
865 if err != nil {650 if err != nil {
@@ -874,13 +659,15 @@ func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
874 return result.Backups, nil659 return result.Backups, nil
875 }660 }
876 661
877-// RunBackup triggers a backup for a domain.662+// RunBackup runs one declared database backup of one app. service and
878-func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {663+// database are explicit selectors.
879- if backupDomain == "" {664+func (c *Client) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) {
880- return nil, fmt.Errorf("domain cannot be empty")665+ if app == "" {
666+ return nil, fmt.Errorf("app cannot be empty")
881 }667 }
882 668
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})
884 if err != nil {671 if err != nil {
885 return nil, err672 return nil, err
886 }673 }
@@ -893,30 +680,11 @@ func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*d
893 return &result, nil680 return &result, nil
894 }681 }
895 682
896-// DetectDatabases detects supported databases for a domain.683+// ListVolumeBackups returns volume backups globally or for one app.
897-func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {684+func (c *Client) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, 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) {
917 path := "/backups/volumes"685 path := "/backups/volumes"
918- if backupDomain != "" {686+ if app != "" {
919- path += "/" + url.PathEscape(backupDomain)687+ path += "/" + url.PathEscape(app)
920 }688 }
921 689
922 resp, err := c.request(ctx, http.MethodGet, path, nil)690 resp, err := c.request(ctx, http.MethodGet, path, nil)
@@ -945,14 +713,15 @@ func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob,
945 return result.Backups, nil713 return result.Backups, nil
946 }714 }
947 715
948-// RunVolumeBackups triggers volume backups.716+// RunVolumeBackups runs one declared volume backup of one app. service
949-func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {717+// and volume are explicit selectors.
950- path := "/backups/volumes"718+func (c *Client) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) {
951- if backupDomain != "" {719+ if app == "" {
952- path += "/" + url.PathEscape(backupDomain)720+ return nil, fmt.Errorf("app cannot be empty")
953 }721 }
722+ path := "/backups/volumes/" + url.PathEscape(app)
954 723
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})
956 if err != nil {725 if err != nil {
957 return nil, err726 return nil, err
958 }727 }
@@ -1006,9 +775,6 @@ type Config struct {
1006 RegistryDomain string `json:"registry_domain"`775 RegistryDomain string `json:"registry_domain"`
1007 DataDir string `json:"data_dir,omitempty"`776 DataDir string `json:"data_dir,omitempty"`
1008 } `json:"server"`777 } `json:"server"`
1009- AutoRoute struct {
1010- Enabled bool `json:"enabled"`
1011- } `json:"auto_route"`
1012 NetworkIsolation struct {778 NetworkIsolation struct {
1013 Enabled bool `json:"enabled"`779 Enabled bool `json:"enabled"`
1014 Prefix string `json:"prefix"`780 Prefix string `json:"prefix"`
@@ -1018,7 +784,6 @@ type Config struct {
1018 Prefix string `json:"prefix"`784 Prefix string `json:"prefix"`
1019 Preserve bool `json:"preserve"`785 Preserve bool `json:"preserve"`
1020 } `json:"volumes"`786 } `json:"volumes"`
1021- Routes []domain.Route `json:"routes"`
1022 ExternalRoutes []ExternalRoute `json:"external_routes"`787 ExternalRoutes []ExternalRoute `json:"external_routes"`
1023 }788 }
1024 789
@@ -1029,97 +794,43 @@ type ExternalRoute struct {
1029 }794 }
1030 795
1031 // GetConfig returns the Gordon configuration.796 // GetConfig returns the Gordon configuration.
1032-func (c *Client) GetConfig(ctx context.Context) (*Config, error) {797+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1033- resp, err := c.request(ctx, http.MethodGet, "/config", nil)798+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
1034 if err != nil {799 if err != nil {
1035 return nil, err800 return nil, err
1036 }801 }
1037 802
1038- var config Config803+ var result struct {
1039- if err := parseResponse(resp, &config); err != nil {804+ Networks []*domain.NetworkInfo `json:"networks"`
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
1066 }805 }
1067-
1068- var result DeployResult
1069 if err := parseResponse(resp, &result); err != nil {806 if err := parseResponse(resp, &result); err != nil {
1070 return nil, err807 return nil, err
1071 }808 }
1072 809
1073- return &result, nil810+ return result.Networks, 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"`
1096 }811 }
1097 812
1098-// Restart triggers a container restart for the specified domain.813+// GetConfig returns the Gordon configuration.
1099-func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {814+func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
1100- if restartDomain == "" {815+ resp, err := c.request(ctx, http.MethodGet, "/config", nil)
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)
1108 if err != nil {816 if err != nil {
1109 return nil, err817 return nil, err
1110 }818 }
1111 819
1112- var result RestartResult820+ var config Config
1113- if err := parseResponse(resp, &result); err != nil {821+ if err := parseResponse(resp, &config); err != nil {
1114 return nil, err822 return nil, err
1115 }823 }
1116 824
1117- return &result, nil825+ return &config, nil
1118 }826 }
1119 827
1120-// Tags API828+// 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+}
1121 833
1122-// ListTags returns available tags for a repository.
1123 func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {834 func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
1124 if repository == "" {835 if repository == "" {
1125 return nil, fmt.Errorf("repository cannot be empty")836 return nil, fmt.Errorf("repository cannot be empty")
@@ -1194,169 +905,6 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line
1194 // Attachments Config API905 // Attachments Config API
1195 906
1196 // ListOrphanedAttachments returns running attachment containers no longer configured.907 // 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.
1360 func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {908 func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1361 streamURL := c.baseURL + "/admin" + path909 streamURL := c.baseURL + "/admin" + path
1362 910
@@ -1377,7 +925,8 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
1377 // Use the same transport as the main client (honoring TLS config and925 // Use the same transport as the main client (honoring TLS config and
1378 // custom transports) but without a timeout so streaming doesn't get cut off.926 // custom transports) but without a timeout so streaming doesn't get cut off.
1379 streamClient := &http.Client{927 streamClient := &http.Client{
1380- Transport: c.httpClient.Transport,928+ Transport: c.httpClient.Transport,
929+ CheckRedirect: c.httpClient.CheckRedirect,
1381 }930 }
1382 resp, err := streamClient.Do(req)931 resp, err := streamClient.Do(req)
1383 if err != nil {932 if err != nil {
M internal/adapters/in/cli/remote/client_test.go +8 -167
@@ -17,7 +17,6 @@ import (
17 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
18 18
19 "github.com/bnema/gordon/internal/adapters/dto"19 "github.com/bnema/gordon/internal/adapters/dto"
20- "github.com/bnema/gordon/internal/domain"
21 )20 )
22 21
23 func withFastRetry(t *testing.T) {22 func withFastRetry(t *testing.T) {
@@ -32,50 +31,6 @@ func withFastRetry(t *testing.T) {
32 })31 })
33 }32 }
34 33
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-
79 func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {34 func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
80 withFastRetry(t)35 withFastRetry(t)
81 36
@@ -97,73 +52,6 @@ func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
97 assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))52 assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
98 }53 }
99 54
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-
167 func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) {55 func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) {
168 withFastRetry(t)56 withFastRetry(t)
169 57
@@ -197,7 +85,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
197 return85 return
198 }86 }
199 w.Header().Set("Content-Type", "application/json")87 w.Header().Set("Content-Type", "application/json")
200- _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))88+ _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
201 }))89 }))
202 defer srv.Close()90 defer srv.Close()
203 91
@@ -206,7 +94,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
206 94
207 require.NoError(t, err)95 require.NoError(t, err)
208 require.NotNil(t, status)96 require.NotNil(t, status)
209- assert.Equal(t, 2, status.Routes)97+ assert.Equal(t, 2, status.Apps)
210 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))98 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
211 }99 }
212 100
@@ -222,7 +110,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
222 return110 return
223 }111 }
224 w.Header().Set("Content-Type", "application/json")112 w.Header().Set("Content-Type", "application/json")
225- _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))113+ _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
226 }))114 }))
227 defer srv.Close()115 defer srv.Close()
228 116
@@ -231,7 +119,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
231 119
232 require.NoError(t, err)120 require.NoError(t, err)
233 require.NotNil(t, status)121 require.NotNil(t, status)
234- assert.Equal(t, 2, status.Routes)122+ assert.Equal(t, 2, status.Apps)
235 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))123 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
236 }124 }
237 125
@@ -259,7 +147,7 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
259 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {147 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260 require.Equal(t, "/admin/status", r.URL.Path)148 require.Equal(t, "/admin/status", r.URL.Path)
261 w.Header().Set("Content-Type", "application/json")149 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":{}}`))
263 }))151 }))
264 defer srv.Close()152 defer srv.Close()
265 153
@@ -355,53 +243,6 @@ func TestClientPruneImages(t *testing.T) {
355 assert.Equal(t, 3, resp.Registry.TagsRemoved)243 assert.Equal(t, 3, resp.Registry.TagsRemoved)
356 }244 }
357 245
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-
405 func TestParseResponse_CapsErrorBodySize(t *testing.T) {246 func TestParseResponse_CapsErrorBodySize(t *testing.T) {
406 largeBody := strings.Repeat("x", 10*1024*1024)247 largeBody := strings.Repeat("x", 10*1024*1024)
407 resp := &http.Response{248 resp := &http.Response{
@@ -589,20 +430,20 @@ func TestParseErrorResponse_NonJSON(t *testing.T) {
589 430
590 func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {431 func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
591 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {432 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)
593 require.Equal(t, http.MethodPost, r.Method)434 require.Equal(t, http.MethodPost, r.Method)
594 w.Header().Set("Content-Type", "application/json")435 w.Header().Set("Content-Type", "application/json")
595 w.WriteHeader(http.StatusPartialContent)436 w.WriteHeader(http.StatusPartialContent)
596 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{437 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
597 Status: "partial",438 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"}},
599 Error: "one volume failed",440 Error: "one volume failed",
600 }))441 }))
601 }))442 }))
602 defer srv.Close()443 defer srv.Close()
603 444
604 client := NewClient(srv.URL)445 client := NewClient(srv.URL)
605- result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")446+ result, err := client.RunVolumeBackups(context.Background(), "shop", "api", "data")
606 447
607 require.Error(t, err)448 require.Error(t, err)
608 assert.Contains(t, err.Error(), "one volume failed")449 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 (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "net/http"7 "net/http"
8+ "os"
9+ "strconv"
8 "strings"10 "strings"
9 11
10 "github.com/bnema/gordon/internal/adapters/in/cli/remote"12 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
12- "github.com/bnema/gordon/pkg/validation"
13 )14 )
14 15
15 type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error)16 type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error)
16 17
17-func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) {18+func inferPushRemote(ctx context.Context, imageArg, _, dockerfile string) (*remote.ResolvedRemote, error) {
18- if domainFlag != "" {
19- return inferRemoteForRouteDomain(ctx, domainFlag)
20- }
21-
22 if imageArg == "" {19 if imageArg == "" {
23 detected, err := detectImageName(dockerfile)20 detected, err := detectImageName(dockerfile)
24 if err != nil {21 if err != nil {
@@ -26,105 +23,20 @@ func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile strin
26 }23 }
27 imageArg = detected24 imageArg = detected
28 }25 }
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-
48 classified := classifyPushArgument(imageArg)26 classified := classifyPushArgument(imageArg)
49- return inferRemoteForImage(ctx, classified.lookupImage)27+ return inferRemoteForImage(ctx, classified.repository)
50 }28 }
51 29
52 func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {30 func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
53 return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {31 return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
54- routes, err := client.FindRoutesByImage(ctx, imageName)32+ tags, err := client.ListTags(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)
121 if err != nil {33 if err != nil {
122 if isRemoteNotFoundError(err) {34 if isRemoteNotFoundError(err) {
123 return false, nil35 return false, nil
124 }36 }
125 return false, err37 return false, err
126 }38 }
127- return true, nil39+ return len(tags) > 0, nil
128 })40 })
129 }41 }
130 42
@@ -153,7 +65,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo
153 65
154 for _, name := range names {66 for _, name := range names {
155 entry := remotes[name]67 entry := remotes[name]
156- matched, err := probe(ctx, newRoutesTargetClient(name, entry))68+ matched, err := probe(ctx, newInferenceTargetClient(name, entry))
157 if err != nil {69 if err != nil {
158 probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err))70 probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err))
159 continue71 continue
@@ -183,7 +95,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo
183 }95 }
184 96
185 func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) {97 func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) {
186- if _, ok := resolveRoutesExplicitTarget(); ok {98+ if _, ok := resolveExplicitTargetName(); ok {
187 return nil, false99 return nil, false
188 }100 }
189 101
@@ -205,8 +117,8 @@ func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.Reso
205 return &remote.ResolvedRemote{117 return &remote.ResolvedRemote{
206 Name: name,118 Name: name,
207 URL: entry.URL,119 URL: entry.URL,
208- Token: resolveRoutesTokenForTarget(name, entry),120+ Token: resolveTokenForTarget(name, entry),
209- InsecureTLS: resolveRoutesInsecureForTarget(name, entry),121+ InsecureTLS: resolveInsecureForTarget(name, entry),
210 }122 }
211 }123 }
212 124
@@ -217,12 +129,86 @@ func isRemoteNotFoundError(err error) bool {
217 return errors.Is(err, domain.ErrRouteNotFound)129 return errors.Is(err, domain.ErrRouteNotFound)
218 }130 }
219 131
220-func filterPreviewRoutes(routes []domain.Route) []domain.Route {132+func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) {
221- filtered := make([]domain.Route, 0, len(routes))133+ target, ok := resolveExplicitTargetName()
222- for _, route := range routes {134+ if !ok {
223- if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {135+ return nil, false, nil
224- filtered = append(filtered, route)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
225 }159 }
226 }160 }
227- return filtered161+
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
228 }214 }
M internal/adapters/in/cli/remotes.go +1 -1
@@ -296,7 +296,7 @@ without needing to specify --remote.
296 296
297 Examples:297 Examples:
298 gordon remotes use prod298 gordon remotes use prod
299- gordon routes list # Uses prod remote automatically`,299+ gordon status # Uses prod remote automatically`,
300 Args: cobra.ExactArgs(1),300 Args: cobra.ExactArgs(1),
301 RunE: func(cmd *cobra.Command, args []string) error {301 RunE: func(cmd *cobra.Command, args []string) error {
302 name := args[0]302 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 (
13 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
14 14
15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16- "github.com/bnema/gordon/internal/app"
17 )16 )
18 17
19 var (18 var (
@@ -50,16 +49,16 @@ func NewRootCmd() *cobra.Command {
50 tokenFlag = token49 tokenFlag = token
51 return nil50 return nil
52 },51 },
53- Short: "Gordon - A lightweight container deployment platform",52+ Short: "Gordon - A self-hosted container deployment platform",
54- Long: `Gordon is a self-contained container deployment platform that combines53+ Long: `Gordon is a self-hosted container deployment platform: a private
55-a Docker registry with automatic container deployment capabilities.54+container registry, an app runtime, and a reverse proxy.
56 55
57-It listens for image pushes and automatically deploys containers based on56+Applications are declared in standalone TOML files and managed with
58-configuration rules, making it ideal for single-server deployments.57+gordon apps; push transfers OCI content only and never deploys.
59 58
60 Commands are organized by where they run:59 Commands are organized by where they run:
61- Server-only: Run on the machine hosting Gordon (serve, auth, reload)60+ Server-only: Run on the machine hosting Gordon (serve, auth, ca)
62- Management: Work locally or remotely via --remote flag (routes, secrets, etc.)61+ Management: Work locally or remotely via --remote flag (apps, secrets, etc.)
63 Client-only: CLI utilities that don't require a running Gordon server`,62 Client-only: CLI utilities that don't require a running Gordon server`,
64 }63 }
65 64
@@ -88,35 +87,14 @@ Commands are organized by where they run:
88 caCmd.GroupID = groupServer87 caCmd.GroupID = groupServer
89 rootCmd.AddCommand(caCmd)88 rootCmd.AddCommand(caCmd)
90 89
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-
100 secretsCmd := newSecretsCmd()90 secretsCmd := newSecretsCmd()
101 secretsCmd.GroupID = groupManage91 secretsCmd.GroupID = groupManage
102 rootCmd.AddCommand(secretsCmd)92 rootCmd.AddCommand(secretsCmd)
103 93
104- deployCmd := newDeployCmd()
105- deployCmd.GroupID = groupManage
106- rootCmd.AddCommand(deployCmd)
107-
108- restartCmd := newRestartCmd()
109- restartCmd.GroupID = groupManage
110- rootCmd.AddCommand(restartCmd)
111-
112 pushCmd := newPushCmd()94 pushCmd := newPushCmd()
113 pushCmd.GroupID = groupManage95 pushCmd.GroupID = groupManage
114 rootCmd.AddCommand(pushCmd)96 rootCmd.AddCommand(pushCmd)
115 97
116- pinCmd := newPinCmd()
117- pinCmd.GroupID = groupManage
118- rootCmd.AddCommand(pinCmd)
119-
120 reloadCmd := newReloadCmd()98 reloadCmd := newReloadCmd()
121 reloadCmd.GroupID = groupManage99 reloadCmd.GroupID = groupManage
122 rootCmd.AddCommand(reloadCmd)100 rootCmd.AddCommand(reloadCmd)
@@ -137,10 +115,6 @@ Commands are organized by where they run:
137 imagesCmd.GroupID = groupManage115 imagesCmd.GroupID = groupManage
138 rootCmd.AddCommand(imagesCmd)116 rootCmd.AddCommand(imagesCmd)
139 117
140- bootstrapCmd := newBootstrapCmd()
141- bootstrapCmd.GroupID = groupManage
142- rootCmd.AddCommand(bootstrapCmd)
143-
144 configCmd := newConfigCmd()118 configCmd := newConfigCmd()
145 configCmd.GroupID = groupManage119 configCmd.GroupID = groupManage
146 rootCmd.AddCommand(configCmd)120 rootCmd.AddCommand(configCmd)
@@ -153,14 +127,6 @@ Commands are organized by where they run:
153 volumesCmd.GroupID = groupManage127 volumesCmd.GroupID = groupManage
154 rootCmd.AddCommand(volumesCmd)128 rootCmd.AddCommand(volumesCmd)
155 129
156- autorouteCmd := newAutorouteCmd()
157- autorouteCmd.GroupID = groupManage
158- rootCmd.AddCommand(autorouteCmd)
159-
160- previewCmd := newPreviewCmd()
161- previewCmd.GroupID = groupManage
162- rootCmd.AddCommand(previewCmd)
163-
164 tlsCmd := newTLSCmd()130 tlsCmd := newTLSCmd()
165 tlsCmd.GroupID = groupManage131 tlsCmd.GroupID = groupManage
166 rootCmd.AddCommand(tlsCmd)132 rootCmd.AddCommand(tlsCmd)
@@ -169,6 +135,10 @@ Commands are organized by where they run:
169 trafficCmd.GroupID = groupManage135 trafficCmd.GroupID = groupManage
170 rootCmd.AddCommand(trafficCmd)136 rootCmd.AddCommand(trafficCmd)
171 137
138+ appsCmd := newAppsCmd()
139+ appsCmd.GroupID = groupManage
140+ rootCmd.AddCommand(appsCmd)
141+
172 // Client-only commands (no server needed)142 // Client-only commands (no server needed)
173 remotesCmd := newRemotesCmd()143 remotesCmd := newRemotesCmd()
174 remotesCmd.GroupID = groupClient144 remotesCmd.GroupID = groupClient
@@ -222,21 +192,23 @@ func IsRemoteMode() bool {
222 func newReloadCmd() *cobra.Command {192 func newReloadCmd() *cobra.Command {
223 return &cobra.Command{193 return &cobra.Command{
224 Use: "reload",194 Use: "reload",
225- Short: "Start containers for configured routes",195+ Short: "Reload installation configuration (never activates app state)",
226- Long: `Starts containers for routes defined in config.toml that don't have196+ Long: `Reloads installation-only settings (entrypoints, TLS, limits,
227-a running container. Running containers are never restarted to ensure 100% uptime.197+external routes) after editing gordon.toml.
228 198
229-Use this command after editing config.toml to add new routes, or after pushing199+Reload never activates pending app desired state, never re-resolves
230-images to the registry when the route was not yet configured.`,200+image tags, and never starts app workloads. Apps are managed with
201+gordon apps (apply, deploy, lifecycle).`,
231 RunE: func(cmd *cobra.Command, args []string) error {202 RunE: func(cmd *cobra.Command, args []string) error {
232- client, isRemote, err := GetRemoteClient()203+ handle, err := resolveControlPlane(cliConfigPath)
233 if err != nil {204 if err != nil {
234 return err205 return err
235 }206 }
236- if isRemote {207+ defer handle.close()
237- return runReloadRemote(cmd.Context(), client)208+ if err := handle.plane.Reload(cmd.Context()); err != nil {
209+ return fmt.Errorf("failed to reload: %w", err)
238 }210 }
239- return runReload()211+ return cliWriteLine(cmd.OutOrStdout(), cliRenderSuccess("Configuration reloaded successfully"))
240 },212 },
241 }213 }
242 }214 }
@@ -262,34 +234,9 @@ func newVersionCmd() *cobra.Command {
262 }234 }
263 }235 }
264 236
265-// runReload sends SIGUSR1 to the running Gordon process.237+// cliConfigPath for local operations. If empty, config is auto-discovered
266-func runReload() error {238+// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
267- return app.SendReloadSignal()239+var cliConfigPath string
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-}
293 240
294 // newLogsCmd creates the logs command.241 // newLogsCmd creates the logs command.
295 func newLogsCmd() *cobra.Command {242 func newLogsCmd() *cobra.Command {
@@ -298,29 +245,23 @@ func newLogsCmd() *cobra.Command {
298 var logsConfigPath string245 var logsConfigPath string
299 246
300 cmd := &cobra.Command{247 cmd := &cobra.Command{
301- Use: "logs [domain]",248+ Use: "logs",
302- Short: "Show logs (Gordon process or container)",249+ Short: "Show Gordon process logs",
303- Long: `Shows logs from the Gordon process or a specific container.250+ Long: `Shows logs from the Gordon daemon process.
304 251
305-Without a domain argument, shows Gordon process logs.252+Application workload output is read with gordon apps logs APP --service SVC,
306-With a domain argument, shows container logs for that domain.253+which resolves the app's active container through the daemon.
307 254
308 Examples:255 Examples:
309- gordon logs # Gordon process logs256+ gordon logs # Gordon process logs
310- gordon logs -f # Follow process logs257+ gordon logs -f # Follow process logs
311- gordon logs myapp.local # Container logs for myapp.local258+ gordon logs -n 100 # Last 100 lines
312- gordon logs myapp.local -f # Follow container logs
313 259
314 Remote mode:260 Remote mode:
315- gordon logs --remote https://gordon.mydomain.com --token $TOKEN261+ gordon logs --remote https://gordon.mydomain.com --token $TOKEN`,
316- gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,262+ Args: cobra.NoArgs,
317- Args: cobra.MaximumNArgs(1),
318 RunE: func(cmd *cobra.Command, args []string) error {263 RunE: func(cmd *cobra.Command, args []string) error {
319- logDomain := ""264+ return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout())
320- if len(args) > 0 {
321- logDomain = args[0]
322- }
323- return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
324 },265 },
325 }266 }
326 267
@@ -332,101 +273,23 @@ Remote mode:
332 }273 }
333 274
334 // runLogs handles the logs command logic.275 // runLogs handles the logs command logic.
335-func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {276+func runLogs(ctx context.Context, logsConfigPath string, follow bool, lines int, out io.Writer) error {
336- if logDomain != "" {277+ handle, err := resolveControlPlane(logsConfigPath)
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()
346 if err != nil {278 if err != nil {
347 return err279 return err
348 }280 }
349- if isRemote {281+ defer handle.close()
350- return runLogsRemote(ctx, client, logDomain, follow, lines, out)282+ return runProcessLogs(ctx, handle.plane, follow, lines, out)
351- }
352- return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
353 }283 }
354 284
355-// runLogsRemote fetches logs from a remote Gordon instance.285+func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int, out io.Writer) error {
356-func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
357 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)286 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
358 defer cancel()287 defer cancel()
359 288
360 if follow {289 if follow {
361- return streamLogsRemote(ctx, client, logDomain, lines, out)290+ ch, err := cp.StreamProcessLogs(ctx, lines)
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)
378 if err != nil {291 if err != nil {
379- return fmt.Errorf("failed to get container logs: %w", err)292+ return fmt.Errorf("failed to stream process 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)
430 }293 }
431 for line := range ch {294 for line := range ch {
432 if err := cliWriteLine(out, line); err != nil {295 if err := cliWriteLine(out, line); err != nil {
@@ -436,9 +299,9 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo
436 return nil299 return nil
437 }300 }
438 301
439- logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)302+ logLines, err := cp.GetProcessLogs(ctx, lines)
440 if err != nil {303 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)
442 }305 }
443 for _, line := range logLines {306 for _, line := range logLines {
444 if err := cliWriteLine(out, line); err != nil {307 if err := cliWriteLine(out, line); err != nil {
@@ -448,31 +311,6 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo
448 return nil311 return nil
449 }312 }
450 313
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-
476 // SetVersionInfo sets the version information for the CLI.314 // SetVersionInfo sets the version information for the CLI.
477 func SetVersionInfo(version, commit, date string) {315 func SetVersionInfo(version, commit, date string) {
478 Version = version316 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 (
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7 7
8- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"8 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"9 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
11 10
@@ -16,11 +15,12 @@ import (
16 func newSecretsCmd() *cobra.Command {15 func newSecretsCmd() *cobra.Command {
17 cmd := &cobra.Command{16 cmd := &cobra.Command{
18 Use: "secrets",17 Use: "secrets",
19- Short: "Manage secrets",18+ Short: "Manage installation secrets",
20- Long: `Manage secrets (environment variables) for routes and attachments.19+ Long: `Manage installation secrets (per-host entries such as service auth).
21 20
22-Secrets are stored per-domain and injected into containers as environment variables.21+Application secret VALUES are managed with gordon apps secrets and stay
23-Use --attachment to target attachment containers (databases, caches, etc.).22+in pass under gordon/apps/<uuid>/<service>/<name>. The commands below
23+manage the installation secret store only.
24 24
25 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),25 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
26 these commands operate on the remote server.`,26 these commands operate on the remote server.`,
@@ -43,7 +43,6 @@ func newSecretsListCmd() *cobra.Command {
43 Long: `List all secret keys configured for a domain.43 Long: `List all secret keys configured for a domain.
44 44
45 Note: Only secret keys are shown, not values (for security).45 Note: Only secret keys are shown, not values (for security).
46-Attachment secrets (for services like databases) are also displayed.
47 46
48 Examples:47 Examples:
49 gordon secrets list app.mydomain.com48 gordon secrets list app.mydomain.com
@@ -64,28 +63,22 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
64 ctx := cmd.Context()63 ctx := cmd.Context()
65 secretDomain := args[0]64 secretDomain := args[0]
66 65
67- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)66+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
68 if err != nil {67 if err != nil {
69 return err68 return err
70 }69 }
71 defer handle.close()70 defer handle.close()
72 71
73- keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain)72+ keys, err := fetchSecrets(ctx, handle.plane, secretDomain)
74 if err != nil {73 if err != nil {
75 return err74 return err
76 }75 }
77 76
78- totalSecrets := len(keys)77+ if len(keys) == 0 {
79- for _, att := range attachments {
80- totalSecrets += len(att.Keys)
81- }
82-
83- if totalSecrets == 0 {
84 if jsonOut {78 if jsonOut {
85 return writeJSON(cmd.OutOrStdout(), map[string]any{79 return writeJSON(cmd.OutOrStdout(), map[string]any{
86- "domain": secretDomain,80+ "domain": secretDomain,
87- "keys": []string{},81+ "keys": []string{},
88- "attachments": []any{},
89 })82 })
90 }83 }
91 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))84 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 {
93 }86 }
94 87
95 if jsonOut {88 if jsonOut {
96- if attachments == nil {
97- attachments = []remote.AttachmentSecrets{}
98- }
99 return writeJSON(cmd.OutOrStdout(), map[string]any{89 return writeJSON(cmd.OutOrStdout(), map[string]any{
100- "domain": secretDomain,90+ "domain": secretDomain,
101- "keys": keys,91+ "keys": keys,
102- "attachments": attachments,
103 })92 })
104 }93 }
105 94
@@ -110,7 +99,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
110 fmt.Println(styles.Theme.Title.Render(title))99 fmt.Println(styles.Theme.Title.Render(title))
111 fmt.Println()100 fmt.Println()
112 101
113- rows := buildSecretsTableRows(keys, attachments)102+ rows := buildSecretsTableRows(keys)
114 103
115 table := components.NewTable(104 table := components.NewTable(
116 components.WithColumns([]components.TableColumn{105 components.WithColumns([]components.TableColumn{
@@ -124,120 +113,39 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
124 return nil113 return nil
125 }114 }
126 115
127-// fetchSecretsWithAttachments retrieves secrets from the selected control plane.116+// fetchSecrets retrieves secret keys from the selected control plane.
128-func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) {117+func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) {
129- result, err := cp.ListSecretsWithAttachments(ctx, secretDomain)118+ result, err := cp.ListSecrets(ctx, secretDomain)
130 if err != nil {119 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)
132 }121 }
133- return result.Keys, result.Attachments, nil122+ return result.Keys, nil
134 }123 }
135 124
136-// buildSecretsTableRows builds table rows with tree structure for attachments.125+// buildSecretsTableRows builds table rows for secret keys.
137-func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string {126+func buildSecretsTableRows(keys []string) [][]string {
138 var rows [][]string127 var rows [][]string
139-
140- // Domain secrets first
141 for _, key := range keys {128 for _, key := range keys {
142 rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})129 rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
143 }130 }
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-
175 return rows131 return rows
176 }132 }
177 133
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-
223 // newSecretsSetCmd creates the secrets set command.134 // newSecretsSetCmd creates the secrets set command.
224 func newSecretsSetCmd() *cobra.Command {135 func newSecretsSetCmd() *cobra.Command {
225- var attachment, fromFile string136+ var fromFile string
226 var jsonOut bool137 var jsonOut bool
227 138
228 cmd := &cobra.Command{139 cmd := &cobra.Command{
229 Use: "set <domain> --from-file <path>",140 Use: "set <domain> --from-file <path>",
230- Short: "Set secrets for a domain or attachment",141+ Short: "Set installation secrets for a domain",
231- Long: `Set one or more secrets for a domain or an attachment container.142+ Long: `Set one or more installation secrets for a domain.
232 143
233 Secrets are read from a mode 0600 file containing one KEY=value pair per line.144 Secrets are read from a mode 0600 file containing one KEY=value pair per line.
234-145+For app secret values, use gordon apps secrets instead.
235-Use --attachment to target an attachment service (e.g., postgres, redis) instead
236-of the main domain container.
237 146
238 Examples:147 Examples:
239- gordon secrets set app.mydomain.com --from-file ./app.env148+ gordon secrets set app.mydomain.com --from-file ./app.env`,
240- gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
241 Args: cobra.ExactArgs(1),149 Args: cobra.ExactArgs(1),
242 RunE: func(cmd *cobra.Command, args []string) error {150 RunE: func(cmd *cobra.Command, args []string) error {
243 ctx := cmd.Context()151 ctx := cmd.Context()
@@ -256,29 +164,20 @@ Examples:
256 secrets[parts[0]] = parts[1]164 secrets[parts[0]] = parts[1]
257 }165 }
258 166
259- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)167+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
260 if err != nil {168 if err != nil {
261 return err169 return err
262 }170 }
263 defer handle.close()171 defer handle.close()
264- if attachment != "" {172+ if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
265- if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {173+ return fmt.Errorf("failed to set secrets: %w", err)
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- }
272 }174 }
273 175
274 target := secretDomain176 target := secretDomain
275- if attachment != "" {
276- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
277- }
278 177
279 if jsonOut {178 if jsonOut {
280 return writeJSON(cmd.OutOrStdout(), map[string]any{179 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,
282 })181 })
283 }182 }
284 if len(secrets) == 1 {183 if len(secrets) == 1 {
@@ -290,7 +189,6 @@ Examples:
290 },189 },
291 }190 }
292 191
293- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
294 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")192 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")193 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
296 _ = cmd.MarkFlagRequired("from-file")194 _ = cmd.MarkFlagRequired("from-file")
@@ -300,23 +198,18 @@ Examples:
300 198
301 // newSecretsRemoveCmd creates the secrets remove command.199 // newSecretsRemoveCmd creates the secrets remove command.
302 func newSecretsRemoveCmd() *cobra.Command {200 func newSecretsRemoveCmd() *cobra.Command {
303- var (201+ var force bool
304- force bool
305- attachment string
306- )
307 202
308 cmd := &cobra.Command{203 cmd := &cobra.Command{
309 Use: "remove <domain> <key>",204 Use: "remove <domain> <key>",
310- Short: "Remove a secret",205+ Short: "Remove an installation secret",
311- Long: `Remove a secret from a domain or an attachment container.206+ Long: `Remove an installation secret from a domain.
312 207
313-Use --attachment to target an attachment service (e.g., postgres, redis) instead208+For app secret values, use gordon apps secrets delete instead.
314-of the main domain container.
315 209
316 Examples:210 Examples:
317 gordon secrets remove app.mydomain.com OLD_API_KEY211 gordon secrets remove app.mydomain.com OLD_API_KEY
318- gordon secrets remove app.mydomain.com OLD_API_KEY --force212+ gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
319- gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
320 Args: cobra.ExactArgs(2),213 Args: cobra.ExactArgs(2),
321 RunE: func(cmd *cobra.Command, args []string) error {214 RunE: func(cmd *cobra.Command, args []string) error {
322 ctx := cmd.Context()215 ctx := cmd.Context()
@@ -324,9 +217,6 @@ Examples:
324 key := args[1]217 key := args[1]
325 218
326 target := secretDomain219 target := secretDomain
327- if attachment != "" {
328- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
329- }
330 220
331 // Confirm unless --force221 // Confirm unless --force
332 if !force {222 if !force {
@@ -342,19 +232,13 @@ Examples:
342 }232 }
343 }233 }
344 234
345- handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)235+ handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
346 if err != nil {236 if err != nil {
347 return err237 return err
348 }238 }
349 defer handle.close()239 defer handle.close()
350- if attachment != "" {240+ if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
351- if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {241+ return fmt.Errorf("failed to remove secret: %w", err)
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- }
358 }242 }
359 243
360 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))244 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
@@ -363,7 +247,6 @@ Examples:
363 }247 }
364 248
365 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")249 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
366- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
367 250
368 return cmd251 return cmd
369 }252 }
M internal/adapters/in/cli/serve.go +3 -3
@@ -10,19 +10,19 @@ import (
10 10
11 // newServeCmd creates the serve command.11 // newServeCmd creates the serve command.
12 func newServeCmd() *cobra.Command {12 func newServeCmd() *cobra.Command {
13- var configPath string13+ var cliConfigPath string
14 14
15 cmd := &cobra.Command{15 cmd := &cobra.Command{
16 Use: "serve",16 Use: "serve",
17 Short: "Start the Gordon server",17 Short: "Start the Gordon server",
18 Long: `Start the Gordon server, including the registry and proxy components.`,18 Long: `Start the Gordon server, including the registry and proxy components.`,
19 RunE: func(cmd *cobra.Command, args []string) error {19 RunE: func(cmd *cobra.Command, args []string) error {
20- return app.Run(context.Background(), configPath)20+ return app.Run(context.Background(), cliConfigPath)
21 },21 },
22 }22 }
23 23
24 // Add flags24 // Add flags
25- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")25+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
26 26
27 return cmd27 return cmd
28 }28 }
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 {
33 Use: "status",33 Use: "status",
34 Short: "Show public TLS certificate status",34 Short: "Show public TLS certificate status",
35 RunE: func(cmd *cobra.Command, _ []string) error {35 RunE: func(cmd *cobra.Command, _ []string) error {
36- handle, err := tlsResolveControlPlane(configPath)36+ handle, err := tlsResolveControlPlane(cliConfigPath)
37 if err != nil {37 if err != nil {
38 return err38 return err
39 }39 }
M internal/adapters/in/cli/traffic_status.go +1 -1
@@ -20,7 +20,7 @@ func newTrafficStatusCmd() *cobra.Command {
20 Short: "Show traffic entrypoint, router, and counter status",20 Short: "Show traffic entrypoint, router, and counter status",
21 Args: cobra.NoArgs,21 Args: cobra.NoArgs,
22 RunE: func(cmd *cobra.Command, args []string) error {22 RunE: func(cmd *cobra.Command, args []string) error {
23- handle, err := trafficResolveControlPlane(configPath)23+ handle, err := trafficResolveControlPlane(cliConfigPath)
24 if err != nil {24 if err != nil {
25 return err25 return err
26 }26 }
M internal/adapters/in/cli/traffic_test.go +3 -24
@@ -4,7 +4,6 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7- "errors"
8 "net/http"7 "net/http"
9 "net/http/httptest"8 "net/http/httptest"
10 "testing"9 "testing"
@@ -13,6 +12,7 @@ import (
13 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
14 13
15 "github.com/bnema/gordon/internal/adapters/dto"14 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18 )18 )
@@ -81,12 +81,12 @@ func TestTrafficStatusJSONOutput(t *testing.T) {
81 81
82 func TestRunTrafficStatusUsesControlPlane(t *testing.T) {82 func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
83 status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"}83 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()
85 86
86 var buf bytes.Buffer87 var buf bytes.Buffer
87 require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))88 require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))
88 89
89- assert.True(t, cp.called)
90 assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)90 assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)
91 }91 }
92 92
@@ -109,24 +109,3 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
109 assert.Contains(t, output, "ok")109 assert.Contains(t, output, "ok")
110 assert.Contains(t, output, "udp=3")110 assert.Contains(t, output, "udp=3")
111 }111 }
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
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6- "go/ast"
7- "go/parser"
8- "go/token"
9 "io"6 "io"
10- "strings"
11 "sync"7 "sync"
12 "testing"8 "testing"
13 9
10+ "github.com/stretchr/testify/mock"
11+
14 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
15 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"13 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
16 )14 )
17 15
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-
31 // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and16 // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
32 // cliWritef variables. Any test that overrides these seams must hold this17 // cliWritef variables. Any test that overrides these seams must hold this
33 // mutex for the duration of the override and restore the originals on cleanup.18 // mutex for the duration of the override and restore the originals on cleanup.
@@ -54,102 +39,6 @@ func TestPresentationHelpers(t *testing.T) {
54 }39 }
55 }40 }
56 41
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-
153 func TestUIAdoptionRuntimeSeams(t *testing.T) {42 func TestUIAdoptionRuntimeSeams(t *testing.T) {
154 uiAdoptionSeamMu.Lock()43 uiAdoptionSeamMu.Lock()
155 defer uiAdoptionSeamMu.Unlock()44 defer uiAdoptionSeamMu.Unlock()
@@ -190,7 +79,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
190 }79 }
191 80
192 pruneMock := climocks.NewMockimagesClient(t)81 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()
194 83
195 pruneBuf := new(bytes.Buffer)84 pruneBuf := new(bytes.Buffer)
196 if err := runImagesPrune(context.Background(), pruneMock, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {85 if err := runImagesPrune(context.Background(), pruneMock, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
@@ -204,16 +93,3 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
204 t.Fatal("expected cliWritef to be called")93 t.Fatal("expected cliWritef to be called")
205 }94 }
206 }95 }
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 {
40 Use: "list",40 Use: "list",
41 Short: "List all volumes",41 Short: "List all volumes",
42 RunE: func(cmd *cobra.Command, _ []string) error {42 RunE: func(cmd *cobra.Command, _ []string) error {
43- handle, err := resolveControlPlane(configPath)43+ handle, err := resolveControlPlane(cliConfigPath)
44 if err != nil {44 if err != nil {
45 return err45 return err
46 }46 }
@@ -56,9 +56,14 @@ func newVolumesPruneCmd() *cobra.Command {
56 var opts volumesPruneOptions56 var opts volumesPruneOptions
57 cmd := &cobra.Command{57 cmd := &cobra.Command{
58 Use: "prune",58 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.`,
60 RunE: func(cmd *cobra.Command, _ []string) error {65 RunE: func(cmd *cobra.Command, _ []string) error {
61- handle, err := resolveControlPlane(configPath)66+ handle, err := resolveControlPlane(cliConfigPath)
62 if err != nil {67 if err != nil {
63 return err68 return err
64 }69 }
@@ -66,7 +71,7 @@ func newVolumesPruneCmd() *cobra.Command {
66 return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())71 return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())
67 },72 },
68 }73 }
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")
70 cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")75 cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")
71 cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")76 cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")
72 return cmd77 return cmd
@@ -133,33 +138,27 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
133 return err138 return err
134 }139 }
135 140
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-
143 if opts.DryRun {141 if opts.DryRun {
144- if opts.Json {142+ return renderVolumePrunePlan(out, opts.Json, preview)
145- return writeJSON(out, preview)143+ }
146- }144+ if preview.Plan.Eligible == 0 {
147- return renderPrunePreview(out, preview)145+ // A zero-eligibility plan is a normal outcome: the report explains
146+ // why each volume survived.
147+ return renderVolumePrunePlan(out, opts.Json, preview)
148 }148 }
149 149
150 if !opts.Json {150 if !opts.Json {
151 if err := renderPrunePreview(out, preview); err != nil {151 if err := renderPrunePreview(out, preview); err != nil {
152 return err152 return err
153 }153 }
154- }154+ if !opts.NoConfirm {
155-155+ confirmed, err := components.RunConfirm("Remove these volumes?")
156- if !opts.NoConfirm && !opts.Json {156+ if err != nil {
157- confirmed, err := components.RunConfirm("Remove these volumes?")157+ return err
158- if err != nil {158+ }
159- return err159+ if !confirmed {
160- }160+ return cliWriteLine(out, "Cancelled.")
161- if !confirmed {161+ }
162- return cliWriteLine(out, "Cancelled.")
163 }162 }
164 }163 }
165 164
@@ -167,10 +166,25 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
167 if err != nil {166 if err != nil {
168 return err167 return err
169 }168 }
170-
171 if opts.Json {169 if opts.Json {
172 return writeJSON(out, result)170 return writeJSON(out, result)
173 }171 }
174 172
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)
176 }190 }
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
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "errors"
5 "net/http"6 "net/http"
6 7
7 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
@@ -84,6 +85,10 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
84 if err != nil {85 if err != nil {
85 log := zerowrap.FromCtx(ctx)86 log := zerowrap.FromCtx(ctx)
86 log.Error().Err(err).Msg("failed to prune volumes")87 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+ }
87 h.sendError(w, http.StatusInternalServerError, "failed to prune volumes")92 h.sendError(w, http.StatusInternalServerError, "failed to prune volumes")
88 return93 return
89 }94 }
@@ -100,5 +105,6 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
100 VolumesRemoved: report.VolumesRemoved,105 VolumesRemoved: report.VolumesRemoved,
101 SpaceReclaimed: report.SpaceReclaimed,106 SpaceReclaimed: report.SpaceReclaimed,
102 Volumes: vols,107 Volumes: vols,
108+ Plan: toPruneSummary(report.Plan),
103 })109 })
104 }110 }
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 {
28 logSvc in.LogService28 logSvc in.LogService
29 volumeSvc in.VolumeService29 volumeSvc in.VolumeService
30 publicTLSSvc in.PublicTLSService30 publicTLSSvc in.PublicTLSService
31+ appSvc in.AppService
31 cleanup func()32 cleanup func()
32 }33 }
33 34
@@ -90,6 +91,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
90 logSvc: svc.logSvc,91 logSvc: svc.logSvc,
91 volumeSvc: svc.volumeSvc,92 volumeSvc: svc.volumeSvc,
92 publicTLSSvc: svc.publicTLSSvc,93 publicTLSSvc: svc.publicTLSSvc,
94+ appSvc: svc.appSvc,
93 cleanup: wrappedCleanup,95 cleanup: wrappedCleanup,
94 }, nil96 }, nil
95 } else {97 } else {
@@ -150,4 +152,6 @@ func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc }
150 152
151 func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc }153 func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc }
152 154
155+func (k *Kernel) Apps() in.AppService { return k.appSvc }
156+
153 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }157 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