detached f19f453

release: Gordon v3.0.0 - declarative apps (#299)

* 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

* feat(apps): fix migration friction and add internal HTTP interfaces (#282)

* fix(apps): reduce migration friction

* feat(apps): add internal HTTP interfaces

* fix(apps): harden internal deployment lifecycle

* fix(apps): preserve targeted deployment state (#283)

* fix(apps): preserve networks in active diffs

* fix(apps): allow safe targeted deployments

* fix(apps): preserve non-targeted services

* fix(apps): parse shared network tables

* refactor(deployment): use durable sequential app deploys (#284)

* refactor(deployment): use sequential service replacement

Replace the rolling-versus-interrupted split for app services with one
sequential replacement applied to every service:

  preflight -> withdraw traffic (confirmed) -> retire the superseded
  container (confirmed gone) -> create and start the replacement ->
  readiness probe -> publish ACTIVE -> rebuild traffic

A deploy may briefly interrupt a service, and two Gordon-managed
generations of one service never run at the same time.

* refactor(deployment): drop httpEligible, deployHTTP, deployInterrupted,
  cutoverService, retireContainerID, drainWithDeadline and the
  retire-after-publish path
* refactor(deployment): restart always restarts the same pinned container
  in place (withdraw, restart, readiness, republish) and no longer creates
  a second container
* refactor(deployment): resolve the implicit HTTP readiness probe from the
  service spec so readiness no longer depends on how a service is
  replaced, keeping the check for stateless public HTTP services
* refactor(deployment): remove DeployResult.Interrupted and
  ServiceResult.Retire/RetireGrace
* test(deployment): replace the rolling-behaviour tests with sequential
  replacement, in-place restart, readiness-ordering, withdrawal-failure,
  cancellation, recovery-inhibition and multi-service failure tests
* docs: describe sequential replacement, the expected short interruption,
  preflight protection and manifest-based rollback

* fix(deployment): recover interrupted replacements safely

Close the crash window left by sequential replacement and the recovery
dead ends the review of it exposed.

* fix(deployment): journal the replacement's container ID before it is
  started, and keep it in the journal when a replacement fails after
  creating its container, so a leftover stays traceable
* fix(deployment): reconcile an unfinished operation at boot and before
  every mutation of an app: remove a never-published candidate (releasing
  its loopback claims) before creating or rebuilding a generation, keep
  the published generation, retry the leftovers of an operation that
  already failed, and finalize the operation (failure, or success when
  every step had already completed) so a keyed retry never reads a
  permanent in-flight claim
* fix(deployment): refuse a new journal while a different operation of the
  same app is unfinished, so a claim can never mask an interrupted
  predecessor
* fix(deployment): a restart that rebuilds a missing generation clears the
  recovery inhibition it wrote for the container proven gone, which
  otherwise refused every later start, recovery pass, and restart
* refactor(deployment): recover the store before reconciling in deploy,
  move the preflight store recovery to its callers, and extract
  runtimeImageRef, convergeCandidate, and interruptedOutcome
* refactor(config): remove the dead [deploy] configuration surface
  (pull_policy, readiness_*, health_timeout, stabilization_delay, probe
  timeouts, drain_*) from the viper defaults, the example configuration,
  and the docs: no code ever read these keys
* test(deployment): cover the candidate journal write before start, boot
  and mutation-time reconciliation, terminal-operation leftovers, an
  interruption after the last step, the claim guard, restart rebuilding a
  missing container, and the inhibition cleanup
* docs: document crash recovery, the 15 s republish cadence, and restart
  rebuilding from the pinned digest

* fix(deployment): fail closed on orphan replacements and repair recovery

Close the recovery findings left by the sequential-replacement review.

* fix(deployment): fail closed when a replacement candidate cannot be removed.
  Reconciliation returns an error, keeps the journal (and its latest position)
  instead of finalizing it, and no later mutation proceeds, so a newer
  operation can never mask an orphan that may still run.
* fix(deployment): clear the superseded container's stale replacement-pending
  inhibition once its never-published candidate is confirmed gone, so
  boot/start/restart can rebuild the generation ACTIVE records. The inhibition
  is kept while candidate cleanup fails, preserving the single writer.
* fix(deployment): set Service on start/restart placeholder journal steps so a
  recovered step is attributable and its inhibition can be cleared.
* fix(deployment): repair the Preflight(key)->Deploy(same key) contract:
  Preflight reconciles before resolving and never claims the caller's request
  key, so a later Deploy of the same key executes instead of replaying the
  preflight journal as a conflict.
* refactor(deployment): drop the unused bool result of verifyRunningService.
* test(deployment): cover fail-closed candidate removal, stale inhibition
  removal with a volume-owning rebuild, the repaired preflight/deploy key
  contract, and preflight reconciliation of an unfinished predecessor.
* docs: describe fail-closed reconciliation and inhibition removal.

* refactor(deployment): split deploy engine into start and execute phases

Factor the synchronous deploy engine into a durable two-phase internal
API. StartDeploy runs under the app lock: it reconciles any interrupted
predecessor, resolves the revision and runs the convergence checks,
atomically claims the request key, and persists a non-terminal journal.
It performs no image pull, pinning, or workload mutation, and reports
whether the caller newly owns the operation or is an idempotent replay.

ExecuteDeploy reacquires the app lock, loads and verifies the claimed
journal by app/op identity, runs image pull/pinning and the existing
sequential replacement, and records the terminal outcome. A terminal or
mismatched claim is never executed: it replays instead. Cancellation or
shutdown mid-execution leaves the non-terminal journal for reconciliation.

Service.Deploy stays the synchronous composition of both phases, so all
existing callers and behavior are unchanged. Existing preflight is
re-expressed as claimDeploymentLocked plus pinPreflightLocked.

* feat(apps): orchestrate two-phase deploy in the background

AppServiceImpl.Deploy now claims and persists the operation through
StartDeploy and, for the key owner only, schedules ExecuteDeploy on a
daemon-owned context so the request returns the running journal promptly
and request cancellation never aborts the replacement. Duplicate keys
replay their stored journal and never spawn a second execution.

A daemon context plus WaitGroup on AppServiceImpl gives the lifecycle
its graceful shutdown hook: Shutdown cancels in-flight executions,
which leave the persisted non-terminal journal for reconciliation, and
waits for them to unwind.

* feat(app): wire daemon lifecycle context into app administration

Build AppServiceImpl through newAppDaemonService with the
daemon/supervisor lifecycle context, so background deploy executions are
owned by the daemon and never by a request context.

Graceful shutdown registers AppServiceImpl.Shutdown on the bounded 30s
shutdown context as Phase 0.5, cancelling and joining in-flight deploy
executions before traffic, runtime, or the app state store is torn down.
A failed unwind is logged and never aborts teardown.

* feat(apps): answer 202 for running deploys and quiesce kernel app admin

The admin deploy handler now answers 202 Accepted with the durable running journal when it newly owns the operation, so the request returns promptly and the client observes completion via GET operations/by-key. A terminal replay stays 200, and conflicts, preflight failures, and failed replays keep their existing 409 mapping. AppDeployResponse carries a stable status field (running until terminal, then the persisted outcome); the by-key lookup keeps answering 200 and remains the recovery source. Permission checks and Idempotency-Key validation are unchanged, and the remote client's generic 2xx path already accepts 202.

Kernel.Close now cancels and joins the daemon-owned AppServiceImpl on a bounded context before any remaining kernel resource is torn down, so a background deploy execution cannot outlive the state and runtime it uses. A nil implementation is never stored in the lifecycle interface.

* feat(apps): poll 202 deploy operations and add operations watch

`gordon apps deploy` now follows a backend 202 through the existing
operations/by-key endpoint until the operation is terminal, printing
concise progress only when operation or step state changes. JSON mode
keeps stdout to a single final document (progress goes to stderr) and
terminal partial/failed outcomes exit nonzero.

Add `gordon apps operations watch APP --key KEY` to resume polling an
existing operation without reissuing the mutation. Ctrl-C stops only
local polling, never the daemon-side operation, and prints the resumable
command in human mode. 404 fails fast; transient polling errors are
bounded; local and remote planes share the same client path.

Docs: apps deploy/operations sections and CLI index.

* fix(deployment): keep live deploy claims out of foreground reconciliation

Track the operations this process owns between the StartDeploy claim and the
ExecuteDeploy exit, so a foreground mutation landing in the hand-off window
cannot reconcile and terminalize a live claim. ExecuteDeploy now always
reloads the operation from the store by app/op identity and refuses a
terminal or mismatched record, never executing a stale in-process journal.

* fix(apps): settle deploy claims raced by shutdown before scheduling

Gate AppServiceImpl.Deploy on the daemon lifecycle before StartDeploy, so a
request arriving after shutdown began is refused with a wrapped
domain.ErrAppStateConflict and never claims a journal or answers 202. Enroll
the Deploy hand-off with the lifecycle so Shutdown waits for an in-flight claim
to be either scheduled or settled, closing the window where shutdown starts
after StartDeploy but before scheduleExecution.

When shutdown wins that race, settle the owned claim instead of dropping it:
add deployment.Service.AbandonDeploy, which releases the in-process live marker
and converges the still non-terminal journal as interrupted, so no accepted
durable claim stays marked live forever and a later claim recovers cleanly.

Add deterministic tests for stopping before a deploy and for shutdown racing
between the claim and scheduling, plus an engine test proving the settled claim
is terminal and never blocks the next owner.

* fix(apps): reuse deploy watch for accepted apply --deploy

`apps apply --deploy` now follows a 202 accepted deploy through the same
by-key watch as `apps deploy`: progress only when the operation or step
state changes, one terminal journal, and a nonzero exit for a terminal
partial/failed outcome without hiding the successful apply.

JSON mode emits exactly one final combined {apply,deploy} document and
sends progress, transient warnings, and Ctrl-C resume guidance to stderr;
no initial running document is written and the deploy is never re-POSTed.
The command now threads cmd.ErrOrStderr so stderr routing is exercised.

Add focused tests for 202 to success, partial/failure with preserved
apply, unchanged-state deduplication, JSON purity, single mutation, and
Ctrl-C resume in both human and JSON modes.

* fix(apps): return journal shape for running deploy replays

A same-key deploy replay whose operation is still in flight now always answers 409 with the stored operation journal, before or after any service step has started. Only an absent or terminal journal may map a preflight error envelope, so a non-terminal replay never degrades to the mapped error response.

* refactor(deployment): remove dead exported Service.Preflight

Remove the production-dead deployment.Service.Preflight entry point and
its now-unreachable preflightLocked wrapper. No production caller and no
boundary interface exposed it; only direct tests did. The private
preflight gates (claimDeploymentLocked/pinPreflightLocked) used by
StartDeploy/ExecuteDeploy are preserved.

Delete the tests and helpers that existed only for the removed entry
point.

* test(deployment): restore preflight gate coverage through deploy phases

Retarget the live preflight gates that were only reachable through the removed Service.Preflight onto StartDeploy/ExecuteDeploy and the synchronous Deploy composition: missing required secret (ErrAppSecretMissing), env/secret collision (ErrInvalidAppSpec), unmanaged image volume (ErrAppUnmanagedImageVolume), R2 unowned existing volume refusal (ErrAppStateConflict), the targeted-deploy diff policy, and the shared GC barrier held across resource selection.

Every failing gate proves no workload mutation before the failure, and the execute-phase gates prove a terminal failed journal. No production code changes.

* fix(deployment): address HTTP readiness at declared container port

qBittorrent rejects the readiness probe with 401 "Invalid Host header, port mismatch" because the probe dials the random loopback published port and Go derives the Host header from it. Keep dialing the published bind but send 127.0.0.1:<declared container readiness port> as the Host authority, matching what the workload listens on.

Thread the Host authority through the injectable httpGet dependency so tests can assert both the dial URL and the Host header, and cover the mismatch with a server that rejects published-port authorities.

* fix(deployment): harden sequential deploy lifecycle

Address the review findings on the sequential service replacement work:

- keep the revision resolved at claim time authoritative for the whole
  execution instead of re-resolving desired state in the second phase
- record successful candidate convergence by clearing the step After and
  keeping the container id in Detail, so a converged step is not retried
  while its audit trail survives
- normalize the persisted service-step plan when a claim is resumed and
  converge recorded candidates before the plan is replaced
- fail closed when app administration does not quiesce: skip state and
  runtime teardown and propagate the error to the process entry point
- carry app and op fields through zerowrap contexts for the deploy
  scheduling logs instead of the service logger
- move the operations watch command into apps_operations_watch.go
- exercise the new admin deploy handler cases over a real httptest server

* feat(apps): add allowlisted CDI devices for app services (#285)

* feat(apps): add allowlisted CDI devices for app services

Manifests declare logical devices via devices = [...]; the
installation maps each name to explicit CDI IDs plus exact
app/service allowlists under [app_devices.<name>]. Resolution
happens at activation time and encodes one native CDI DeviceRequest;
revisions persist logical names only. Revoked grants and unsupported
engines fail closed before any workload mutation.

* refactor(apps): extract helpers to satisfy gocyclo budget

Split device diff entry, preflight error tail, and reload policy
validation out of diffService, isMappedPreflightError, and
applyLoadedConfig.

* fix(apps): address device review findings

Probe engine CDI capability in preflight via a new
ContainerRuntime.SupportsCDIDevices boundary method so unsupported
engines fail before workload mutation. Preserve ErrRuntimeUnsupported
through createContainer redaction and map it to the structured
runtime-unsupported envelope. Stop echoing resolved CDI IDs in
duplicate-grant errors and run union resolution at apply time.

* fix(apps): harden device error sanitization

Hoist the runtime-unsupported sentinel above the bind redaction
branch so mixed bind+device creates keep the structured envelope.
Sanitize the preflight engine probe cause so daemon endpoints never
reach journals or API responses.

* fix(apps): harden device policy, engine gate, and probe errors

Address review findings on the CDI device work:

- docs: move `devices` above the first nested service table so TOML
  assigns it to the service, not to the last bind entry.
- domain: keep the ErrInvalidAppSpec chain through device policy
  allowlist validation, and reject CDI device names that are host paths
  or raw device nodes instead of valid CDI names.
- docker: parse engine major/minor strictly, so prerelease suffixes such
  as "-rc1" fail closed instead of clearing the version gate; keep
  cancellation and deadlines from the /version probe while dropping the
  cause text that embeds the daemon endpoint.
- deployment: preserve cancellation and deadlines from the capability
  probe and collapse every other cause to ErrRuntimeUnsupported alone.
- appstate: load the legacy no-devices record from a verbatim fixture
  instead of writing through the current serializer.

* fix(traffic): harden UDP session lifecycle (#286)

* fix(traffic): harden UDP session lifecycle

* fix(traffic): await UDP runtime shutdown

* docs: prepare declarative apps release as v3 (#287)

* docs(urls): update Gordon public links

* docs(version): rename declarative apps release to v3

* docs(upgrade): clarify v3 migration guidance

* refactor(apps): key manifest services by name (#288)

* refactor(apps): key manifest services by name

* fix(config): drop retired routes table from config example

* fix(apps): restrict retired-shape hint to array tables

* feat(apps): require --service or --all to deploy/restart multi-service apps (#292)

* feat(apps): require --service or --all to deploy/restart multi-service apps

App-wide deploy and restart of an app with several services now fail before any claim with ErrAppServiceScope (HTTP 400 service-scope-required), listing the sorted services. The admin API accepts 'all' (deploy body, restart query) and the CLI adds a mutually exclusive --all flag. apps apply --deploy keeps deploying every service.

Closes #290

* fix(apps): scope check counts the revision to be deployed

An unscoped deploy of an older revision slipped past requireServiceScope
when the desired head and ACTIVE held a single service but the selected
revision declared several: StartDeploy would then replace every service
in that revision. Resolve the revision to activate first (explicit
revision, else desired head) and count its services before claiming.
Wrap the LoadDesired/LoadActive/LoadRevision scope reads with app
context while preserving the underlying store errors.

* feat(telemetry): export proxy access and app container logs over OTLP (#293)

* refactor(telemetry): add Metrics port so registry and eventbus stop importing the adapter

* feat(apps): add telemetry.logs opt-out at app and service level

* feat(telemetry): export proxy access and app container logs over OTLP

* fix(telemetry): export container output from collector start, not follower start

* fix(telemetry): sanitize UTF-8, drop url.query, wrap state errors, persist resume cursors

* fix(apps): skip unchanged services on deploy, apply secret changes on deploy (#294)

* fix(apps): name the diverging path when a targeted deploy is refused

* fix(apps): skip unchanged services on deploy and recreate containers on restart

* fix(apps): keep deploy skip and restart safe for withdrawn services and missing secrets

* fix(apps): apply secret changes on deploy and keep restart in place

* fix(telemetry): export managed container gauge from v3 app state (#291)

Replace the unused gordon.container.managed UpDownCounter with an observable gauge that counts ACTIVE app service containers (excluding stopped apps) at each collection. Closes #289.

* chore(deps): bump Go dependencies (#295)

* chore(deps): bump Go dependencies (grpc v1.84.0 fixes GHSA DoS alert)

* chore(mocks): regenerate mocks with mockery v3.8

* refactor(cli)!: v3 command layout and remove domain secrets (#296)

* refactor(cli)!: remove domain secrets, move push under images, add daemon group

- Remove the v2 per-domain installation secrets stack (CLI, admin
  /secrets API, SecretService, domain secret store, env loader, env file
  migration, [env] config). No v3 code path read these values.
- /admin/secrets now answers 410 endpoint-retired.
- gordon push -> gordon images push.
- gordon status/logs/reload/config/tls/traffic/networks -> gordon daemon <cmd>.
- tls, traffic and networks are single commands under daemon.

* docs: document daemon and images push commands, drop domain secrets

- Add docs/cli/daemon.md covering status, logs, reload, config, tls,
  traffic, and networks; remove the per-command pages it replaces.
- Fold push into docs/cli/images.md.
- Remove gordon secrets, [env], env-variables, and admin:secrets docs;
  point users to gordon apps secrets.
- Reject a leftover [env] config section with a config-retired
  diagnostic; drop dead env/container-name domain helpers.
- Update README, guides, wiki, CI examples, and upgrade notes.

* refactor: deepen daemon wiring, reload, app-state, deployment, and CLI modules (#297)

* refactor(cli): let remote.Client implement ControlPlane directly

Remove the remoteControlPlane pass-through wrapper. Both the explicit
remote and the local admin socket already use *remote.Client, so the
wrapper only forwarded calls and duplicated error context.

* refactor(deployment): split candidate creation out of the deploy flow

Move candidate container construction (env, volumes, image pull, create,
journal, start) to candidate.go and return one startedCandidate value
instead of four results. Backend bind handling, diagnostics, interrupted
deploy convergence, and removed-service retirement move to their own
files so execute.go reads as the deploy flow.

* refactor(appstate): narrow app-state ports and hide apply sequencing

Replace the stage/commit/materialize/GC call sequence in the apps use
case with one AcceptApply method on the store, so the ordering invariant
lives in the adapter. The apps use cases now depend on AppApplyStore and
AppCatalogReader instead of the full AppState port. ListRevisions,
LoadApplyIntent, and ListIntents leave the port; they had no use-case
callers and stay on the concrete store for tests.

* refactor(app): replace reload setter hooks with one ordered runtime step

Move the reload coordinator to reload.go and replace the
SetContainerConfigApplier and SetAppMountPoliciesApplier closures with a
reloadRuntime passed at construction. It derives the container config
and app bind/device policies before any side effect, so an invalid
policy no longer leaves management hosts and traffic already updated.

* refactor(app): split daemon wiring out of run.go by concern

Move config loading, service construction, app engine wiring, auth and
secrets, internal credentials, HTTP edge handlers, listeners, schedulers,
shutdown, and PID file handling into dedicated files. run.go keeps Run
and the server lifecycle (4213 -> ~380 lines). No behavior change.

* fix(app): name the failing reload runtime step in errors

* chore(app): drop orphaned nolint directive left by the run.go split

* fix(app): build reload TLS config before any runtime side effect

Load the static TLS keypair in reloadRuntime.prepare and rebuild the
traffic graph before updating management hosts, so a failed keypair
load or rejected graph leaves PKI and public TLS hosts unchanged.

* fix(app): stop started schedulers when a later scheduler fails to start

* fix(deployment): persist service removal before clearing its inhibition

Save ACTIVE after each removed service and only then clear its recovery
inhibition, so a failed save or later removal never leaves ACTIVE
listing a service recovery is free to recreate.

* fix(security): validate child manifest digests, cap log tail allocation, restrict CI token (#300)
commit
f19f4534cf76ee8c20abe12f37f25fb63ee0df62
author
bnema · · 2026-10-01 18:46 UTC
committer
GitHub · · 2026-10-01 18:46 UTC
parent
da9b3d2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v3.0.0
592 files · +65603 -62565
M .github/workflows/ci.yml +22 -24
@@ -5,6 +5,9 @@ on:
55 branches: [main]
66 pull_request:
77
8+permissions:
9+ contents: read
10+
811 jobs:
912 changes:
1013 name: Detect changes
@@ -13,6 +16,7 @@ jobs:
1316 pull-requests: read
1417 outputs:
1518 go: ${{ steps.filter.outputs.go }}
19+ installer: ${{ steps.filter.outputs.installer }}
1620 steps:
1721 - uses: actions/checkout@v6
1822 - uses: dorny/paths-filter@v3
@@ -26,6 +30,22 @@ jobs:
2630 - '.golangci.yml'
2731 - 'Makefile'
2832 - '.github/workflows/**'
33+ installer:
34+ - 'install.sh'
35+ - 'tests/install_next_test.sh'
36+ - '.github/workflows/ci.yml'
37+
38+ installer:
39+ name: Installer
40+ needs: changes
41+ if: needs.changes.outputs.installer == 'true'
42+ runs-on: ubuntu-latest
43+ steps:
44+ - uses: actions/checkout@v6
45+ - name: Check installer scripts
46+ run: |
47+ shellcheck -s sh install.sh tests/install_next_test.sh
48+ sh tests/install_next_test.sh
2949
3050 lint:
3151 name: Lint
@@ -38,14 +58,7 @@ jobs:
3858 - uses: actions/setup-go@v5
3959 with:
4060 go-version: "1.27.x"
41-
42- - name: Cache Go modules
43- uses: actions/cache@v5
44- with:
45- path: ~/go/pkg/mod
46- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
47- restore-keys: |
48- ${{ runner.os }}-go-mod-
61+ cache: true
4962
5063 - name: Run golangci-lint
5164 uses: golangci/golangci-lint-action@v9
@@ -63,22 +76,7 @@ jobs:
6376 - uses: actions/setup-go@v5
6477 with:
6578 go-version: "1.27.x"
66-
67- - name: Cache Go modules
68- uses: actions/cache@v5
69- with:
70- path: ~/go/pkg/mod
71- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
72- restore-keys: |
73- ${{ runner.os }}-go-mod-
74-
75- - name: Cache Go build cache
76- uses: actions/cache@v5
77- with:
78- path: ~/.cache/go-build
79- key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
80- restore-keys: |
81- ${{ runner.os }}-go-build-
79+ cache: true
8280
8381 - name: Run tests
8482 run: go test -race -v ./...
M .goreleaser.yaml +1 -1
@@ -23,7 +23,7 @@ builds:
2323 env:
2424 - CGO_ENABLED=0
2525 ldflags:
26- - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
26+ - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.dirty={{.IsGitDirty}}
2727
2828 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)
2929 # dockers:
M .mockery.yaml +14 -6
@@ -17,20 +17,28 @@ packages:
1717 EventSubscriber:
1818 EventHandler:
1919 SecretProvider:
20- EnvLoader:
2120 ContainerLogWriter:
22- AttachmentConfigProvider:
21+ Metrics:
22+ LogExporter:
23+ ContainerLogStreamer:
2324 TokenStore:
24- DomainSecretStore:
25+ AppState:
26+ AppStateReader:
27+ ImageResolver:
28+ SecretWriter:
2529 RateLimiter:
2630 BackupStorage:
27- RouteChecker:
2831 HTTPChallengeSink:
2932 PublicCertificateIssuer:
3033 CertificateStore:
3134 SecretResolver:
3235 CloudflareZoneResolver:
3336 CertificateAuthority:
37+ AppTrafficRefresher:
38+ PruneProtectionStore:
39+ PruneRuntime:
40+ GCBarrier:
41+ GCLease:
3442 github.com/bnema/gordon/internal/boundaries/in:
3543 interfaces:
3644 ContainerService:
@@ -42,12 +50,12 @@ packages:
4250 AuthService:
4351 HealthService:
4452 HTTPProber:
45- SecretService:
4653 LogService:
4754 VolumeService:
4855 PublicTLSService:
4956 TrafficStatusService:
50- StandaloneServiceService:
57+ AppService:
58+ AppReconciler:
5159 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
5260 # Mocked here because it abstracts Docker SDK calls that require a running
5361 # daemon, making unit/integration tests impractical without a test double.
M AGENTS.md +3 -4
@@ -111,10 +111,9 @@ CLI commands do NOT use zerowrap — they use `cliWriteLine`/`cliWritef` for out
111111
112112 ### ControlPlane Pattern
113113
114-`ControlPlane` interface (`controlplane.go`) abstracts local vs remote operations.
115-- `controlplane_remote.go` — delegates to `remote.Client` HTTP methods.
116-- `controlplane_local.go` — calls service interfaces directly.
117-- Test fakes in `push_test.go` — update when adding interface methods.
114+`ControlPlane` interface (`controlplane.go`) is the seam CLI commands depend on.
115+- `*remote.Client` implements it for both the explicit remote and the local admin socket.
116+- Tests use the mockery mock in `cli/mocks/` — run `mockery` after adding interface methods.
118117
119118 ### HTTP Admin Handlers
120119
M Dockerfile +7 -1
@@ -5,6 +5,11 @@ ARG BUILDPLATFORM
55 ARG VERSION=dev
66 ARG COMMIT=unknown
77 ARG BUILD_DATE=unknown
8+# Whether the source checkout had uncommitted changes. The Makefile and the
9+# release tooling pass an explicit true/false value. Because .git is excluded
10+# from the build context (see .dockerignore), an ad-hoc build that passes no
11+# build-arg reports "unknown" rather than falsely claiming a clean checkout.
12+ARG DIRTY=unknown
813
914 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
1015
@@ -13,6 +18,7 @@ ARG TARGETARCH=amd64
1318 ARG VERSION
1419 ARG COMMIT
1520 ARG BUILD_DATE
21+ARG DIRTY
1622
1723 RUN apk add --no-cache git ca-certificates tzdata
1824
@@ -25,7 +31,7 @@ COPY . .
2531
2632 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
2733 -trimpath \
28- -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
34+ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X main.dirty=${DIRTY}" \
2935 -o /gordon ./main.go
3036
3137 FROM alpine:3.22
M Makefile +8 -2
@@ -10,12 +10,17 @@ ENGINE := podman
1010 VERSION := $(shell git describe --tags --always --dirty)
1111 COMMIT := $(shell git rev-parse --short HEAD)
1212 BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')
13+# Probe the working tree directly (tracked edits and untracked files) instead
14+# of parsing VERSION, so an overridden VERSION cannot mask a dirty checkout.
15+# `git describe --dirty`, which VERSION uses, only reflects tracked edits.
16+DIRTY := $(if $(shell git status --porcelain --untracked-files=normal 2>/dev/null),true,false)
1317
1418 # Build flags
1519 LDFLAGS := -s -w \
1620 -X main.version=$(VERSION) \
1721 -X main.commit=$(COMMIT) \
18- -X main.date=$(BUILD_DATE)
22+ -X main.date=$(BUILD_DATE) \
23+ -X main.dirty=$(DIRTY)
1924
2025 # Architectures
2126 ARCHS := amd64 arm64
@@ -83,7 +88,7 @@ build: ## Build binaries for linux (amd64 and arm64)
8388 @echo "Building Go binaries..."
8489 @mkdir -p $(DIST_DIR)
8590 @rm -f $(DIST_DIR)/*
86- @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)"
91+ @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE), dirty $(DIRTY)"
8792 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
8893 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
8994 @echo "Go binaries built successfully"
@@ -105,6 +110,7 @@ build-push: ## Build and push Docker images
105110 --build-arg VERSION="$(VERSION)" \
106111 --build-arg COMMIT="$(COMMIT)" \
107112 --build-arg BUILD_DATE="$(BUILD_DATE)" \
113+ --build-arg DIRTY="$(DIRTY)" \
108114 -t $(REPO):$(TAG)-$$arch .; \
109115 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
110116 done
M README.md +79 -51
@@ -2,57 +2,79 @@
22
33 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
44
5-Self-hosted container deployment. Push an image, Gordon routes it to the web.
5+Self-hosted container deployment. Push an image, declare an app, deploy it.
66
77 - Website: https://bnema.dev/gordon
8-- Documentation: [Docs](https://gordon.bnema.dev/docs) | [Wiki](https://gordon.bnema.dev/wiki)
8+- Documentation: [Docs](https://bnema.dev/gordon/docs) | [Wiki](https://bnema.dev/gordon/wiki)
99 - Discuss: [GitHub Discussions](https://github.com/bnema/gordon/discussions)
1010
1111 ---
1212
1313 ## What is Gordon?
1414
15-Gordon is a private container registry and HTTP reverse proxy for your VPS. Push a container image that exposes a web port — Gordon deploys it with zero downtime.
15+Gordon is a private container registry, an app runtime, and a reverse proxy for your VPS.
16+
17+The flow is explicit: build and push an image, declare the app in a standalone TOML file, apply it, then deploy. Push transfers OCI content only — it never deploys.
1618
1719 ## Quick Start
1820
1921 ```bash
20-# Install
21-curl -fsSL https://gordon.bnema.dev/install.sh | sh
22+# Install the latest stable release to ~/.local/bin
23+curl -fsSL https://bnema.dev/gordon/install | sh
2224
23-# Start the server
25+# Start the server (restart your shell first if the installer updated PATH)
2426 gordon serve
2527 ```
2628
27-Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for full setup.
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://bnema.dev/gordon/docs/installation#choosing-an-install-channel).
32+
33+Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://bnema.dev/gordon/docs/getting-started) for full setup.
2834
2935 ## Deploy with the CLI
3036
31-Build locally, push directly to your Gordon server:
37+Build locally, push to your Gordon server, declare the app, deploy:
3238
3339 ```bash
34-# Push using an explicit domain override
35-gordon push myapp:latest --domain app.example.com
40+# Push the image (OCI transfer only, never deploys)
41+gordon images push myapp --build --remote prod
42+
43+# Declare the app (blog.toml references the pushed tag)
44+gordon apps apply --file blog.toml --remote prod
3645
37-# Or add a route manually, then deploy
38-gordon routes add app.example.com myapp:latest
39-gordon routes deploy app.example.com
46+# Activate it
47+gordon apps deploy blog --remote prod
4048
4149 # Check status
42-gordon status
50+gordon daemon status
51+gordon apps status blog --remote prod
4352 ```
4453
45-Roll back, restart, or manage secrets — all from the command line:
54+Minimal app file (`blog.toml`):
55+
56+```toml
57+name = "blog"
58+
59+[services.web]
60+image = "gordon.mydomain.com/myapp:v1.2.0"
61+
62+[[services.web.http]]
63+host = "blog.mydomain.com"
64+port = 3000
65+```
66+
67+Manage lifecycle and secrets — all from the command line:
4668
4769 ```bash
48-gordon pin app.example.com # Pin to a specific image tag
49-gordon restart app.example.com # Restart the container
50-gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123
70+gordon apps restart blog --remote prod # Restart from pinned digests
71+gordon apps stop blog --remote prod # Stop, preserve all data
72+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
5173 ```
5274
5375 ## Deploy from CI/CD
5476
55-Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on image push.
77+Push to Gordon's registry from any CI pipeline, then apply and deploy with the CLI. Push never triggers a deploy by itself.
5678
5779 ### GitHub Actions
5880
@@ -64,17 +86,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on
6486 password: ${{ secrets.GORDON_TOKEN }}
6587 ```
6688
67-### Docker CLI
89+See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
90+
91+### Docker CLI + Gordon CLI
6892
6993 ```bash
70-docker login registry.mydomain.com
71-docker build -t registry.mydomain.com/myapp:v1.0.0 .
72-docker push registry.mydomain.com/myapp:v1.0.0
73-# -> Deployed automatically
94+docker login gordon.mydomain.com
95+docker build -t gordon.mydomain.com/myapp:v1.0.0 .
96+docker push gordon.mydomain.com/myapp:v1.0.0
97+# Then, with the Gordon binary:
98+gordon apps apply --file blog.toml --remote prod
99+gordon apps deploy blog --remote prod
74100 ```
75101
76-See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
77-
78102 ## CLI Commands
79103
80104 ### Server
@@ -82,36 +106,41 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
82106 | Command | Description |
83107 |---------|-------------|
84108 | `gordon serve` | Start the Gordon server |
85-| `gordon status` | Show server and route health |
86-| `gordon config show` | Display server configuration |
109+| `gordon daemon status` | Show server and app fleet status |
110+| `gordon daemon logs` | Show Gordon process logs |
111+| `gordon daemon reload` | Reload installation configuration |
112+| `gordon daemon config show` | Display installation configuration |
87113
88-### Deployment
114+### Applications
89115
90116 | Command | Description |
91117 |---------|-------------|
92-| `gordon push [image]` | Tag, push, and optionally deploy an image |
93-| `gordon routes list` | List all routes |
94-| `gordon routes add <domain> <image>` | Create or update a route |
95-| `gordon routes remove <domain>` | Remove a route |
96-| `gordon routes deploy <domain>` | Redeploy a route |
97-| `gordon pin <domain>` | Pin a route to a specific image tag |
98-| `gordon restart <domain>` | Restart a route container |
118+| `gordon apps apply --file FILE` | Validate and persist an app manifest |
119+| `gordon apps deploy APP` | Activate an app revision |
120+| `gordon apps list` | List applications |
121+| `gordon apps show APP` | Show desired and active state |
122+| `gordon apps status APP` | Show effective vs observed state |
123+| `gordon apps logs APP` | Show logs for an app service |
124+| `gordon apps restart APP` | Restart from pinned digests |
125+| `gordon apps stop APP` | Stop, preserve all data |
126+| `gordon apps start APP` | Start a stopped app |
127+| `gordon apps remove APP` | Remove workloads (volumes/secrets retained) |
99128
100129 ### Images & Registry
101130
102131 | Command | Description |
103132 |---------|-------------|
133+| `gordon images push [image]` | Tag and push an image (never deploys) |
104134 | `gordon images list` | List runtime and registry images |
105135 | `gordon images prune` | Clean up dangling images and old tags |
106136 | `gordon images tags <repo>` | List registry tags for a repository |
107137
108-### Secrets & Config
138+### Secrets
109139
110140 | Command | Description |
111141 |---------|-------------|
112-| `gordon secrets list <domain>` | List secrets for a route |
113-| `gordon secrets set <domain> KEY=VAL` | Set secrets |
114-| `gordon secrets remove <domain> <key>` | Remove a secret |
142+| `gordon apps secrets list APP` | List app secret names |
143+| `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |
115144
116145 ### Remotes & Auth
117146
@@ -125,23 +154,22 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
125154
126155 ## Features
127156
128-- Private Docker registry on your VPS
157+- Private Docker/Podman registry on your VPS
158+- Declarative apps: one TOML file per app, explicit apply then deploy
129159 - Domain-to-container routing through a smart TCP edge reverse proxy
130-- Automatic deployment on image push
131-- Auto-routing from image labels
132-- Remote CLI management
133-- Zero downtime updates
134-- Persistent volumes from Dockerfile VOLUME directives
135-- Environment variable management with secrets support
136-- Network isolation per application
137-- Single binary, ~15MB RAM
160+- Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish)
161+- Remote CLI management (daemon is the sole writer)
162+- Declarative per-service volumes, retained across lifecycle operations
163+- Per-service secrets in pass, app-wide public env in the manifest
164+- Per-app private networks plus opt-in shared networks
165+- Single binary
138166
139167 > [!NOTE]
140-> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
168+> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements. Use Gordon's ownership-aware volume commands; runtime commands such as `docker volume prune` bypass Gordon's retention checks.
141169
142170 ## Documentation
143171
144-Full documentation at **[gordon.bnema.dev](https://bnema.dev/gordon)**
172+Full documentation at **[bnema.dev/gordon](https://bnema.dev/gordon)**
145173
146174 - [Docs](https://bnema.dev/gordon/docs) — Installation, configuration, CLI reference
147175 - [Wiki](https://bnema.dev/gordon/wiki) — Tutorials, guides, and examples
A docs/cli/apps.md +390 -0
@@ -0,0 +1,390 @@
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+| `operations` | Inspect app operation journals |
33+| `list` | List applications |
34+| `show` | Show desired and active state for an app |
35+| `diff` | Show the normalized desired-vs-active diff |
36+| `secrets` | Manage app secret values |
37+| `deploy` | Activate an app revision |
38+| `restart` | Restart an app from pinned digests |
39+| `stop` | Stop an app (preserves all data) |
40+| `start` | Start a stopped app from active state |
41+| `remove` | Remove app workloads (volumes and secrets are retained) |
42+| `status` | Show effective vs observed state for an app |
43+| `logs` | Show logs for an app service |
44+
45+---
46+
47+## gordon apps apply
48+
49+```bash
50+gordon apps apply --file blog.toml [--dry-run] [--deploy] [--json]
51+```
52+
53+Validates a manifest file and persists it as desired state (or dry-runs).
54+With `--deploy`, chains exactly the accepted revision into a deploy after
55+persistence succeeds; the two outcomes are reported separately because a
56+deploy may fail after the apply succeeded.
57+
58+When the daemon accepts the deploy asynchronously (HTTP 202), the command
59+reuses the same by-key watch as `gordon apps deploy`: it polls the
60+operation journal until terminal, prints progress only when the operation
61+or step state changes, and never reissues the deploy. A terminal
62+`partial`/`failed` deploy exits nonzero while still reporting the
63+successful apply. Ctrl-C stops only local polling: the daemon-side
64+operation keeps running and the command prints how to resume it.
65+
66+With `--json`, stdout carries exactly one final combined document after the
67+deploy reaches a terminal state:
68+
69+```json
70+{
71+ "apply": { "app": "blog", "resulting_revision": "rev-b", "pending": true },
72+ "deploy": { "op": "op-1", "app": "blog", "status": "success", "outcome": "success" }
73+}
74+```
75+
76+No initial running document is emitted, and progress, transient warnings,
77+and Ctrl-C resume guidance go to stderr.
78+
79+`--dry-run` and `--deploy` cannot be combined.
80+
81+---
82+
83+## gordon apps list
84+
85+```bash
86+gordon apps list [--json]
87+```
88+
89+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.
90+
91+---
92+
93+## gordon apps show
94+
95+```bash
96+gordon apps show APP [--json]
97+```
98+
99+Shows desired revision and acceptance status, pending state, per-service
100+effective revision, container, digest, and restart safety, the resources the
101+app owns (volumes, secret paths, image references — never secret values),
102+stopped intent, and the last operation with its outcome.
103+
104+---
105+
106+## gordon apps operations show
107+
108+```bash
109+gordon apps operations show APP --key KEY [--json]
110+```
111+
112+Recovers one operation journal entry by its client-generated request key.
113+`--key` is required. Use it to re-query an ambiguous mutation outcome before
114+retrying: repeating the same key replays the recorded result, while a key
115+reused for a different request is refused.
116+
117+### Flags
118+
119+| Flag | Description |
120+|------|-------------|
121+| `--key` | Request key (required) |
122+| `--json` | Output as JSON |
123+
124+---
125+
126+## gordon apps operations watch
127+
128+```bash
129+gordon apps operations watch APP --key KEY [--json]
130+```
131+
132+Polls the operation journal by request key until the operation reaches a
133+terminal state, then renders the terminal journal. It never reissues the
134+mutation, so it is the safe way to resume after an interrupted deploy or to
135+follow an operation started elsewhere. Progress is printed only when the
136+operation or step state changes; unchanged polls are not repeated.
137+
138+Exit status is nonzero for terminal `partial`/`failed` outcomes and for an
139+interrupted watch. Ctrl-C stops only local polling: the daemon-side
140+operation is not cancelled and keeps running, and the command prints the
141+command to resume. With `--json`, stdout carries exactly one final document
142+and progress goes to stderr.
143+
144+### Flags
145+
146+| Flag | Description |
147+|------|-------------|
148+| `--key` | Request key (required) |
149+| `--json` | Output as JSON |
150+
151+---
152+
153+## gordon apps diff
154+
155+```bash
156+gordon apps diff APP [--json]
157+```
158+
159+Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`).
160+
161+---
162+
163+## gordon apps secrets
164+
165+Values are accepted via `KEY=VALUE` arguments (discouraged: shell history) or
166+stdin. Names must already exist in desired or active state. Only key names are
167+ever echoed back — never values. Secrets are service-scoped: `--service` is
168+required for `set` and `delete`, and optional for `list` where it filters to
169+one service.
170+
171+### gordon apps secrets list
172+
173+```bash
174+gordon apps secrets list APP [--service SVC] [--json]
175+```
176+
177+Lists registration metadata for an app's secrets — service, key, name, source,
178+and presence — never secret values. `--service` filters to one service.
179+
180+#### Flags
181+
182+| Flag | Description |
183+|------|-------------|
184+| `--service` | Filter by service |
185+| `--json` | Output as JSON |
186+
187+#### JSON Output
188+
189+```json
190+[
191+ {"service": "web", "key": "DATABASE_URL", "name": "app_database_url", "source": "desired", "presence": "unknown"}
192+]
193+```
194+
195+### gordon apps secrets set
196+
197+```bash
198+gordon apps secrets set APP --service SVC KEY=VALUE… [--json]
199+gordon apps secrets set APP --service SVC --stdin [--json]
200+gordon apps secrets set APP --service SVC --stdin --key KEY [--json]
201+```
202+
203+Reads values in one of three ways:
204+
205+- `KEY=VALUE` arguments: the value must be a single line of 1–65536 bytes.
206+- `--stdin`: reads `KEY=VALUE` lines; blank and whitespace-only lines are
207+ ignored and every non-blank value byte is preserved.
208+- `--stdin --key KEY`: reads one raw value for `KEY`; a single trailing newline
209+ is stripped and the value must still be a single non-empty line.
210+
211+`--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments.
212+An empty value is rejected, so `KEY=` is invalid.
213+
214+Running containers keep the values they were created with. Apply new values
215+with `gordon apps deploy APP --service SVC`: deploy sees the changed secret and
216+recreates the service. `restart` does not apply new values.
217+
218+#### Flags
219+
220+| Flag | Description |
221+|------|-------------|
222+| `--service` | Service the secrets belong to (required) |
223+| `--stdin` | Read `KEY=VALUE` lines, or one raw value with `--key` |
224+| `--key` | Secret key for single-value stdin mode |
225+| `--json` | Output as JSON |
226+
227+#### JSON Output
228+
229+```json
230+{"app": "blog", "service": "web", "keys": ["DATABASE_URL"]}
231+```
232+
233+### gordon apps secrets delete
234+
235+```bash
236+gordon apps secrets delete APP KEY --service SVC [--json]
237+```
238+
239+Deletes the registered value for `KEY`.
240+
241+#### Flags
242+
243+| Flag | Description |
244+|------|-------------|
245+| `--service` | Service the secret belongs to (required) |
246+| `--json` | Output as JSON |
247+
248+---
249+
250+## gordon apps deploy
251+
252+```bash
253+gordon apps deploy APP [--revision REV] [--service SVC | --all] [--json]
254+```
255+
256+Activates a revision (default: desired head). An app with several services
257+needs `--service NAME` (one service) or `--all` (every service); without
258+either, the command fails before any change and lists the services. A
259+single-service app needs neither. `apps apply --deploy` always deploys every
260+service.
261+
262+| Flag | Description |
263+|------|-------------|
264+| `--revision REV` | Revision to activate (default: desired head) |
265+| `--service NAME` | Deploy one service only |
266+| `--all` | Deploy every service |
267+| `--json` | Output as JSON |
268+
269+Deploy is the only command that applies changes. It recreates a service
270+when its image digest, spec, app environment, or secret values changed. A
271+new image behind the same tag (for example `latest`) has a new digest and is
272+deployed. A service already running with all of these unchanged keeps its
273+container and is reported `unchanged`. Services with host binds or devices
274+are always replaced, so bind and device policy changes apply on deploy.
275+
276+Fail-fast across services:
277+the first failure stops the deploy, successful services are preserved,
278+later services stay unchanged.
279+
280+When the daemon accepts the deploy and runs the two phases in the
281+background (HTTP 202), the command polls the operation journal by key until
282+it is terminal and prints concise progress only when the operation or step
283+state changes. It never reissues the mutation. In `--json` mode stdout
284+carries one final document and progress goes to stderr. Ctrl-C stops only
285+local polling (the daemon-side operation keeps running) and prints the
286+`gordon apps operations watch` command to resume.
287+
288+---
289+
290+## gordon apps restart
291+
292+```bash
293+gordon apps restart APP [--service SVC | --all] [--json]
294+```
295+
296+Restarts the same container. Nothing is applied: new secret values, env,
297+image, or config need `gordon apps deploy`. Traffic is withdrawn, the
298+container restarts, readiness is checked, and traffic returns. If the
299+container no longer exists, restart rebuilds it from its pinned digest. An
300+app with several services needs `--service NAME` or `--all`.
301+
302+---
303+
304+## gordon apps stop
305+
306+```bash
307+gordon apps stop APP [--json]
308+```
309+
310+Persists the durable stopped intent and stops exact containers.
311+All data (volumes, secrets) is preserved. Stopped apps stay stopped
312+across reboot.
313+
314+---
315+
316+## gordon apps start
317+
318+```bash
319+gordon apps start APP [--json]
320+```
321+
322+Clears the stopped intent and ensures running from active state.
323+
324+---
325+
326+## gordon apps remove
327+
328+```bash
329+gordon apps remove APP [--json]
330+```
331+
332+Withdraws workloads. Volumes and secrets are retained as owned orphans
333+under the old internal UUID; removing frees the name but never implicitly
334+attaches retained resources to a new app reusing the name.
335+
336+There is deliberately no `purge`: destructive volume deletion requires a
337+separately accepted destructive-action contract.
338+
339+---
340+
341+## gordon apps status
342+
343+```bash
344+gordon apps status APP [--json]
345+```
346+
347+Shows effective vs observed state per service.
348+
349+---
350+
351+## gordon apps logs
352+
353+```bash
354+gordon apps logs APP [--service SVC] [--follow] [--tail N] [--json]
355+```
356+
357+Streams logs for a service in the app's active deployment. The app name and
358+`--service` are the only accepted identity: domains and raw container IDs are
359+never accepted. `--service` is required when the app has several services.
360+
361+---
362+
363+## Workflow Example
364+
365+```bash
366+# Push the image first (OCI transfer only)
367+gordon images push myapp --build --remote prod
368+
369+# Apply the manifest that references the pushed tag
370+gordon apps apply --file blog.toml --remote prod
371+
372+# Write secret values for registered names
373+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
374+
375+# Deploy the accepted revision (polls 202 operations to terminal)
376+gordon apps deploy blog --remote prod
377+
378+# Resume watching an interrupted or externally started operation
379+gordon apps operations watch blog --key <operation-key> --remote prod
380+
381+# Inspect
382+gordon apps show blog --remote prod
383+gordon apps status blog --remote prod
384+```
385+
386+## Related
387+
388+- [CLI Overview](./index.md)
389+- [Images Commands](./images.md#gordon-images-push)
390+- [Deployment](../deployment/index.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 -5
@@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
135135
136136 Registry scopes: `push`, `pull`, `push,pull`
137137
138-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`
138+Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`
139139
140140 Combine scopes with commas:
141141
@@ -271,14 +271,12 @@ Usage:
271271 | `pull` | Pull images from registry |
272272 | `push,pull` | Both push and pull (default) |
273273 | `admin:*:*` | Full admin access |
274-| `admin:routes:read` | Read-only routes access |
275-| `admin:routes:write` | Routes write access |
274+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
275+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
276276 | `admin:config:read` | Read-only config access |
277277 | `admin:config:write` | Config write access |
278278 | `admin:status:read` | Read-only status/health |
279279 | `admin:logs:read` | Read-only logs access |
280-| `admin:secrets:read` | List secret keys |
281-| `admin:secrets:write` | Set/delete secrets |
282280
283281 ## Token Expiry Formats
284282
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 +80 -27
@@ -1,71 +1,124 @@
11 # Backup Command
22
3-Manage database backups and volume backups.
3+Manage app database backups and app volume backups.
44
5-## gordon backups databases
5+Backups are identified by app, service, and the declared resource. Domains are
6+routing addresses and are never a backup identity.
7+
8+## Declaring backup targets
9+
10+A service declares its databases and volumes in the app manifest, and lists
11+which of them are backed up:
12+
13+```toml
14+[services.api]
15+image = "registry.example.com/shop/api:1.4.2"
16+
17+[[services.api.database]]
18+name = "orders"
19+type = "postgres"
20+schedule = "daily"
21+
22+[[services.api.volume]]
23+name = "data"
24+path = "/var/lib/data"
25+
26+[services.api.backup]
27+postgres = ["orders"]
28+volume = ["data"]
29+```
30+
31+A declared database or volume that the service's backup declaration does not
32+reference is not a backup target.
33+
34+Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts
35+only declared databases and volumes, and Gordon never archives an
36+operator-owned host path exposed through a bind.
37+
38+## gordon backups
639
740 Database backups are logical PostgreSQL backups made with `pg_dump`.
841
942 ```bash
10-gordon backups databases <subcommand>
43+gordon backups <subcommand>
1144 ```
1245
1346 Subcommands:
1447
15-- `list [domain]` - List database backups
16-- `run <domain> [--db <name>]` - Trigger an immediate database backup
17-- `detect <domain>` - Detect supported databases for a domain
18-- `status` - Show database backup status
19-
20-Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
48+- `list [app]` - List stored backups, for every app or one app
49+- `run <app> --service <service> --database <database>` - Run one declared
50+ database backup now
51+- `status` - Show stored backups plus declared targets that have no completed
52+ backup yet
2153
22-## gordon backups volumes
54+## gordon backups volume
2355
24-Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
56+Volume backups are best-effort filesystem archives of the app's declared
57+volumes, uploaded to S3.
2558
2659 ```bash
27-gordon backups volumes <subcommand>
60+gordon backups volume <subcommand>
2861 ```
2962
3063 Subcommands:
3164
32-- `list [domain]` - List completed volume backup archives
33-- `run [domain] [--volume <name>]` - Trigger volume backups now
65+- `list [app]` - List completed volume backup archives
66+- `run <app> --service <service> --volume <volume>` - Run one declared volume
67+ backup now
3468 - `status` - Show completed archives plus current/recent in-memory job state
3569
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.
70+Volume archives are not application-consistent unless the application is
71+quiesced or stopped.
72+
73+## Selectors
74+
75+`--service` and the resource flag (`--database`, `--volume`) select the target:
76+
77+- both given: the target must exist, otherwise the command reports not found;
78+- one omitted: it is only allowed when exactly one compatible target remains;
79+- several candidates: the command reports the ambiguity and lists the safe
80+ `service/resource` names.
81+
82+Nothing is ever chosen by guessing.
3783
3884 ## Examples
3985
4086 ```bash
4187 # Database backups
42-gordon backups databases list
43-gordon backups databases run app.example.com --db postgres
44-gordon backups databases detect app.example.com
45-gordon backups databases status
88+gordon backups list
89+gordon backups list shop
90+gordon backups run shop --service api --database orders
91+gordon backups status
4692
4793 # Volume backups
48-gordon backups volumes list
49-gordon backups volumes list app.example.com
50-gordon backups volumes run
51-gordon backups volumes run app.example.com --volume gordon-app-example-com-data
52-gordon backups volumes status
94+gordon backups volume list
95+gordon backups volume list shop
96+gordon backups volume run shop --service api --volume data
97+gordon backups volume status
5398 ```
5499
100+## Scheduling
101+
102+The installation backup schedule runs every declared database whose own
103+`schedule` matches the firing tier, and applies retention under the app name.
104+Volume declarations carry no schedule of their own: the installation volume
105+backup interval runs the declared volume targets.
106+
55107 ## JSON Output
56108
57-Database and volume list commands support `--json`.
109+Every list, run, and status command supports `--json`.
58110
59111 ```bash
60-gordon backups volumes list --json
112+gordon backups volume list --json
61113 ```
62114
63115 ## Required Permissions
64116
65-- Read operations (`list`, `status`, `detect`) require `admin:status:read`.
117+- Read operations (`list`, `status`) require `admin:status:read`.
66118 - `run` requires `admin:config:write`.
67119
68120 ## Related
69121
70122 - [CLI Commands](./index.md)
71123 - [Backups Configuration](../config/backups.md)
124+- [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)
D docs/cli/config.md +0 -72
@@ -1,72 +0,0 @@
1-# Config Commands
2-
3-Inspect Gordon server configuration.
4-
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7-
8-## gordon config
9-
10-### Subcommands
11-
12-| Subcommand | Description |
13-|------------|-------------|
14-| `show` | Show server configuration |
15-
16----
17-
18-## gordon config show
19-
20-Display the Gordon server configuration including server settings,
21-auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default.
22-
23-```bash
24-gordon config show
25-gordon config show --json
26-gordon config show --remote https://gordon.mydomain.com --token $TOKEN
27-```
28-
29-### Flags
30-
31-| Flag | Description |
32-|------|-------------|
33-| `--json` | Output as JSON |
34-
35-### JSON Output
36-
37-```json
38-{
39- "server": {
40- "port": 1111,
41- "registry_port": 5000,
42- "registry_domain": "reg.example.com"
43- },
44- "auto_route": {
45- "enabled": true,
46- "allowed_domains": ["example.com", "*.staging.example.com"]
47- },
48- "network_isolation": {
49- "enabled": true,
50- "prefix": "gordon_"
51- },
52- "routes": [
53- {"domain": "app.example.com", "image": "myapp:latest"}
54- ],
55- "external_routes": [
56- {"domain": "reg.example.com"}
57- ]
58-}
59-```
60-
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.
62-
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-## Related
68-
69-- [CLI Overview](./index.md)
70-- [Auto-Route Commands](./autoroute.md)
71-- [Status Command](./status.md)
72-- [Routes Command](./routes.md)
A docs/cli/daemon.md +518 -0
@@ -0,0 +1,518 @@
1+# Daemon Commands
2+
3+Inspect and operate the Gordon daemon: status, process logs, reload, configuration, TLS, traffic, and networks.
4+
5+These commands target the local daemon through its owner-only socket, or the daemon selected with `--remote`, `GORDON_REMOTE`, or the active remote. See [CLI Overview](./index.md).
6+
7+## gordon daemon
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `status` | Show daemon status and the app fleet summary |
12+| `logs` | Show daemon process logs |
13+| `reload` | Reload installation configuration |
14+| `config show` | Show installation configuration |
15+| `config validate` | Statically validate a local configuration file |
16+| `tls` | Show public TLS certificate status |
17+| `traffic` | Show traffic entrypoint, router, and counter status |
18+| `networks` | List Gordon-managed networks |
19+
20+---
21+
22+## gordon daemon status
23+
24+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`.
25+
26+```bash
27+gordon daemon status
28+gordon daemon status --remote prod
29+```
30+
31+`gordon daemon status` works in local mode and remote mode.
32+
33+- Local mode reads status from in-process services.
34+- Remote mode reads status from the target admin API.
35+
36+### Output
37+
38+```
39+Gordon Status
40+
41+Gordon Domain: gordon.example.com
42+Registry Port: 5000
43+Server Port: 8088
44+Apps: 3
45+Network Isolation: false
46+
47+Container Status:
48+ blog: active
49+ shop: deploying
50+ old-site: stopped
51+```
52+
53+### Information Displayed
54+
55+| Field | Description |
56+|-------|-------------|
57+| Gordon Domain | Public Gordon domain from configuration |
58+| Registry Port | Docker registry port |
59+| Server Port | Gordon admin port |
60+| Apps | Total apps in desired/active state |
61+| Network Isolation | Whether installation network policy is enabled |
62+| Container Status | Fleet status per app (see states below) |
63+
64+### App States
65+
66+| State | Description |
67+|-------|-------------|
68+| active | App deployed and converged on desired state |
69+| deploying | Desired state diverges from effective state |
70+| pending | App applied but never deployed |
71+| stopped | Durable stopped intent (stays stopped across reboot) |
72+
73+### Flags
74+
75+Uses the global remote flags:
76+
77+| Flag | Description |
78+|------|-------------|
79+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
80+| `--token-file` | Read the remote token from a mode 0600 file |
81+
82+### Environment Variables
83+
84+| Variable | Description |
85+|----------|-------------|
86+| `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
87+| `GORDON_TOKEN` | Authentication token |
88+
89+### Examples
90+
91+### Check Local or Remote Status
92+
93+```bash
94+# Local
95+gordon daemon status
96+
97+# Using a saved remote
98+gordon daemon status --remote prod
99+
100+# Using environment variables
101+export GORDON_REMOTE=https://gordon.mydomain.com
102+export GORDON_TOKEN=your-token
103+gordon daemon status
104+```
105+
106+### Quick Fleet Check
107+
108+```bash
109+# Check for non-converged apps
110+gordon daemon status --remote prod | grep -E "(deploying|pending|stopped)"
111+```
112+
113+### Required Permissions (Remote Only)
114+
115+Remote status calls require `admin:status:read` scope in the authentication token.
116+
117+```bash
118+# Generate token with required scope
119+gordon auth token generate --subject admin --scopes admin:status:read
120+```
121+
122+---
123+
124+## gordon daemon logs
125+
126+Display Gordon daemon process logs. Application workload output is read with
127+`gordon apps logs APP --service SVC`, which resolves the app's active container
128+through the daemon.
129+
130+### Synopsis
131+
132+```bash
133+gordon daemon logs [options]
134+```
135+
136+### Options
137+
138+| Option | Short | Default | Description |
139+|--------|-------|---------|-------------|
140+| `--config` | `-c` | Auto | Path to config file |
141+| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
142+| `--lines` | `-n` | 50 | Number of lines to show |
143+| `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
144+| `--token-file` | | | Read the remote token from a mode 0600 file |
145+
146+Remote targeting uses client config or an active remote by default. Use
147+`--remote` and `--token-file` to override. See [CLI Overview](./index.md).
148+
149+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
150+
151+### Examples
152+
153+```bash
154+# Gordon process logs
155+gordon daemon logs # Last 50 lines
156+gordon daemon logs -f # Follow logs
157+gordon daemon logs -n 100 # Last 100 lines
158+gordon daemon logs -f -n 200 # Follow, starting from last 200 lines
159+
160+# App service logs
161+gordon apps logs blog --service web
162+gordon apps logs blog --service web --follow --remote prod
163+
164+# Remote process logs (override)
165+gordon daemon logs --remote prod
166+```
167+
168+### Log Locations
169+
170+```bash
171+# Using gordon daemon logs
172+gordon daemon logs -f
173+
174+# Direct file access
175+tail -f ~/.gordon/logs/gordon.log
176+
177+# With systemd
178+journalctl --user -u gordon -f
179+
180+# App service logs through Gordon
181+gordon apps logs blog --service web --tail 50
182+gordon apps logs blog --service web --follow
183+```
184+
185+---
186+
187+## gordon daemon reload
188+
189+Reload installation configuration.
190+
191+### Synopsis
192+
193+```bash
194+gordon daemon reload
195+```
196+
197+### Description
198+
199+Sends `SIGUSR1` to the running Gordon process, triggering:
200+
201+- Installation settings reload (live keys apply, restart-required keys are reported)
202+- Traffic/proxy state refresh from ACTIVE app projection
203+
204+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.
205+
206+### Example
207+
208+```bash
209+# After editing gordon.toml, apply changes without restart
210+vim ~/.config/gordon/gordon.toml
211+gordon daemon reload
212+```
213+
214+---
215+
216+## gordon daemon config show
217+
218+Display the Gordon installation configuration including server settings,
219+network isolation, volumes, and external route domains. App routes live
220+under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default.
221+
222+```bash
223+gordon daemon config show
224+gordon daemon config show --json
225+gordon daemon config show --remote prod
226+```
227+
228+### Flags
229+
230+| Flag | Description |
231+|------|-------------|
232+| `--json` | Output as JSON |
233+
234+### JSON Output
235+
236+```json
237+{
238+ "server": {
239+ "port": 1111,
240+ "registry_port": 5000,
241+ "registry_domain": "reg.example.com"
242+ },
243+ "network_isolation": {
244+ "enabled": true,
245+ "prefix": "gordon"
246+ },
247+ "volumes": {
248+ "auto_create": true,
249+ "prefix": "gordon",
250+ "preserve": true
251+ },
252+ "external_routes": [
253+ {"domain": "reg.example.com"}
254+ ]
255+}
256+```
257+
258+External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
259+
260+---
261+
262+## gordon daemon config validate
263+
264+Statically validates a candidate configuration file before it is installed.
265+This command is local-only: `--remote` is rejected. Validation is static —
266+runtime, ACTIVE-state, secret, pull, and listener checks are not performed.
267+A file that fails validation exits non-zero; `--json` is still written first.
268+
269+```bash
270+gordon daemon config validate --file ./gordon.toml
271+gordon daemon config validate --file ./gordon.toml --json
272+```
273+
274+### Flags
275+
276+| Flag | Description |
277+|------|-------------|
278+| `--file` | Local candidate configuration file (required) |
279+| `--json` | Output as JSON |
280+
281+### JSON Output
282+
283+On success:
284+
285+```json
286+{
287+ "valid": true,
288+ "diagnostics": [],
289+ "scope": "static"
290+}
291+```
292+
293+On failure, `valid` is `false` and `diagnostics` carries the failure:
294+
295+```json
296+{
297+ "valid": false,
298+ "diagnostics": [
299+ {"code": "config-invalid", "key": "", "message": "configuration failed static validation"}
300+ ],
301+ "scope": "static"
302+}
303+```
304+
305+---
306+
307+## gordon daemon tls
308+
309+Inspect public TLS/ACME certificate status.
310+
311+Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA.
312+
313+ACME challenge notes:
314+
315+- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
316+- HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated.
317+- TLS-ALPN-01 is not supported.
318+
319+Display the current public TLS/ACME certificate status, including ACME mode,
320+certificate details, route coverage, and any errors.
321+
322+```bash
323+gordon daemon tls
324+gordon daemon tls --json
325+gordon daemon tls --remote prod
326+```
327+
328+### Flags
329+
330+| Flag | Description |
331+|------|-------------|
332+| `--json` | Output as JSON |
333+
334+### Human Output
335+
336+```text
337+Public TLS / ACME Status
338+
339+ACME: enabled
340+Configured Mode: auto
341+Effective Mode: http-01
342+Reason: configured
343+Token Source: env
344+
345+Certificates
346+ ID: cert-abc123
347+ Names: example.com, www.example.com
348+ Status: valid
349+ Not After: 2026-05-29 12:00:00
350+
351+Route Coverage
352+ example.com covered=yes covered_by=cert-abc123
353+ internal.local covered=no error=self-signed cert
354+
355+Errors
356+ route internal.local has no ACME cert
357+```
358+
359+### JSON Output
360+
361+```json
362+{
363+ "acme_enabled": true,
364+ "configured_mode": "auto",
365+ "effective_mode": "http-01",
366+ "selection_reason": "configured",
367+ "token_source": "env",
368+ "certificates": [
369+ {
370+ "id": "cert-abc123",
371+ "names": ["example.com", "www.example.com"],
372+ "challenge": "http-01",
373+ "status": "valid",
374+ "not_after": "2026-05-29T12:00:00Z",
375+ "renewal_pending": false
376+ }
377+ ],
378+ "routes": [
379+ {
380+ "domain": "example.com",
381+ "covered": true,
382+ "covered_by": "cert-abc123",
383+ "required_acme": true
384+ },
385+ {
386+ "domain": "internal.local",
387+ "covered": false,
388+ "required_acme": false,
389+ "error": "self-signed cert"
390+ }
391+ ],
392+ "errors": ["route internal.local has no ACME cert"]
393+}
394+```
395+
396+### Token Source
397+
398+The `token_source` field indicates where the ACME token was sourced from
399+(e.g., `env`, `file`, `config`). The token value is never displayed.
400+
401+---
402+
403+## gordon daemon traffic
404+
405+```bash
406+gordon daemon traffic --remote prod
407+gordon daemon traffic --remote https://gordon.example.com --json
408+```
409+
410+Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status.
411+
412+### Flags
413+
414+| Flag | Description |
415+|------|-------------|
416+| `--json` | Output machine-readable JSON |
417+| `--remote`, `-r` | Remote Gordon instance or saved remote name |
418+
419+### JSON Output
420+
421+```json
422+{
423+ "last_reload_status": "ok",
424+ "entrypoints": [
425+ {
426+ "name": "postgres",
427+ "address": "0.0.0.0:5432",
428+ "protocol": "tcp",
429+ "active": true,
430+ "active_tcp_connections": 1,
431+ "active_udp_sessions": 0,
432+ "total_accepted": 12,
433+ "total_refused": 0,
434+ "total_errors": 0,
435+ "bytes_in": 4096,
436+ "bytes_out": 8192,
437+ "smart_tcp": {
438+ "http_accepted": 0,
439+ "h2c_accepted": 0,
440+ "https_fallback_accepted": 0,
441+ "tls_passthrough_accepted": 0,
442+ "raw_fallback_accepted": 0,
443+ "entrypoint_cidr_refused": 0,
444+ "raw_fallback_cidr_refused": 0,
445+ "proxy_refused": 0,
446+ "unknown_no_fallback_refused": 0,
447+ "malformed_rejected": 0,
448+ "sniff_timeout": 0,
449+ "client_hello_too_large": 0
450+ }
451+ }
452+ ],
453+ "routers": [],
454+ "services": [],
455+ "counters": {
456+ "active_tcp_connections": 1,
457+ "active_udp_sessions": 0,
458+ "total_accepted": 12,
459+ "total_refused": 0,
460+ "total_errors": 0,
461+ "bytes_in": 4096,
462+ "bytes_out": 8192,
463+ "smart_tcp": {
464+ "http_accepted": 0,
465+ "h2c_accepted": 0,
466+ "https_fallback_accepted": 0,
467+ "tls_passthrough_accepted": 0,
468+ "raw_fallback_accepted": 0,
469+ "entrypoint_cidr_refused": 0,
470+ "raw_fallback_cidr_refused": 0,
471+ "proxy_refused": 0,
472+ "unknown_no_fallback_refused": 0,
473+ "malformed_rejected": 0,
474+ "sniff_timeout": 0,
475+ "client_hello_too_large": 0
476+ }
477+ }
478+}
479+```
480+
481+---
482+
483+## gordon daemon networks
484+
485+Display Docker networks managed by Gordon, including which containers
486+are connected to each network.
487+
488+```bash
489+gordon daemon networks
490+gordon daemon networks --json
491+gordon daemon networks --remote prod
492+```
493+
494+### Flags
495+
496+| Flag | Description |
497+|------|-------------|
498+| `--json` | Output as JSON |
499+
500+### JSON Output
501+
502+```json
503+[
504+ {
505+ "name": "gordon_myapp",
506+ "driver": "bridge",
507+ "containers": ["container1", "container2"]
508+ }
509+]
510+```
511+
512+## Related
513+
514+- [CLI Overview](./index.md)
515+- [Apps Commands](./apps.md)
516+- [Serve Command](./serve.md)
517+- [Traffic configuration](../config/traffic.md)
518+- [Remote CLI Management](/wiki/guides/remote-cli.md)
M docs/cli/images.md +136 -4
@@ -1,6 +1,6 @@
11 # Images Command
22
3-List and prune runtime/registry images.
3+Push, list, and prune runtime/registry images.
44
55 ## gordon images
66
@@ -10,12 +10,126 @@ gordon images <subcommand>
1010
1111 Subcommands:
1212
13+- `push` - Tag and push an image to the Gordon registry (never deploys).
1314 - `list` - List runtime images and registry tags.
1415 - `prune` - Prune dangling runtime images and old registry tags.
1516 - `tags` - List registry tags for a specific repository.
1617
1718 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
1819
20+## gordon images push
21+
22+Tag and push an image to the Gordon registry. Push transfers OCI content
23+only: it never deploys. Deploy separately with `gordon apps deploy` after
24+applying the manifest that references the pushed tag.
25+
26+### Synopsis
27+
28+```bash
29+gordon images push [image] [options]
30+```
31+
32+### Arguments
33+
34+| Argument | Description |
35+|----------|-------------|
36+| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
37+
38+### Options
39+
40+| Option | Description |
41+|--------|-------------|
42+| `--build` | Build the image first using `docker buildx` |
43+| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
44+| `--platform` | Target platform for buildx (default: `linux/amd64`) |
45+| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
46+| `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
47+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
48+| `--token` | Authentication token for remote |
49+
50+### Description
51+
52+`gordon images push` tags the selected image for the Gordon registry and pushes it.
53+
54+If you do not pass `--remote` and no active remote is configured, Gordon tries to
55+infer the correct saved remote by probing your saved remotes for the image.
56+It auto-selects only when exactly one remote matches. If multiple remotes match,
57+or a saved remote cannot be probed safely, the command stops and asks you to use
58+`--remote` explicitly.
59+
60+Domain-style push targets are retired: push an image name, then
61+`gordon apps apply` the manifest that references the pushed tag.
62+
63+- The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
64+ then falls back to `git describe --tags --dirty` (for example
65+ `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
66+- When `--build` is set, the command builds with `docker buildx build --load`
67+ and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
68+ environment plus any `--build-arg` values. To use these in your Dockerfile,
69+ declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
70+ in build steps.
71+- Use `-f/--file` to build from a Dockerfile outside the current directory root.
72+- The version tag and `latest` are both pushed (unless the version is `latest`).
73+
74+### Authentication
75+
76+When used with `--remote`, gordon images push authenticates in two ways:
77+
78+- **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token
79+- **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
80+
81+This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
82+
83+### Version Auto-Detection
84+
85+Gordon reads version tags from CI environment variables (in priority order):
86+
87+| CI System | Variable | Example |
88+|-----------|----------|---------|
89+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
90+| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
91+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
92+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
93+| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
94+| Fallback | - | `latest` |
95+
96+### Examples
97+
98+```bash
99+# Build, push (auto-detect image name)
100+gordon images push --build --remote https://gordon.example.com
101+
102+# Push an image
103+gordon images push myapp --remote https://gordon.example.com
104+
105+# Push a fully qualified ref
106+gordon images push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com
107+
108+# Push existing local image with an explicit tag
109+gordon images push myapp --tag v1.2.0 --remote https://gordon.example.com
110+
111+# Build for ARM and pass build args
112+gordon images push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com
113+
114+# Build from a custom Dockerfile path
115+gordon images push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com
116+
117+# CI/CD usage (single env var, no docker login needed)
118+export GORDON_TOKEN="your-token"
119+gordon images push myapp --build --remote https://gordon.example.com
120+```
121+
122+### Notes
123+
124+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
125+- `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
126+ install the `docker-buildx-plugin` package.
127+- Gordon uses native registry uploads instead of shelling out to `docker push`.
128+ Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
129+ per-request limit so proxied pushes keep working. Keep the server's
130+ `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
131+ works out of the box.
132+
19133 ## gordon images list
20134
21135 ```bash
@@ -59,11 +173,13 @@ gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry]
59173
60174 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.
61175
176+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).
177+
62178 Flags:
63179
64180 | Flag | Default | Description |
65181 |------|---------|-------------|
66-| `--dry-run` | `false` | Show prune behavior without applying changes |
182+| `--dry-run` | `false` | Run the full inventory and planning, then report; nothing is deleted |
67183 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
68184 | `--dangling` | `false` | Restrict scope to dangling runtime images only |
69185 | `--registry` | `false` | Restrict scope to registry tag retention only |
@@ -80,7 +196,22 @@ Flags:
80196
81197 - `latest` is always preserved when present.
82198 - `--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.
199+- A tag named by durable app state survives beyond the retention window.
200+- `--keep-releases=0` skips registry tag and blob cleanup entirely; dangling runtime prune still runs.
201+
202+## Prune safety
203+
204+Gordon prunes by ownership, not by name or age. Each candidate gets one verdict:
205+
206+| Verdict | Meaning |
207+|---------|---------|
208+| `eligible` | Every fact needed to prove the resource safe was read completely, and no durable record claims it. It is deleted. |
209+| `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. |
210+| `unknown` | A fact needed to prove it safe was missing, unreadable, or unsupported. It survives. |
211+
212+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.
213+
214+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.
84215
85216 ## gordon images tags
86217
@@ -128,7 +259,8 @@ gordon images prune --dangling
128259 # Prune registry tags only, keeping latest + 5 previous
129260 gordon images prune --registry --keep-releases 5
130261
131-# Preview cleanup without applying
262+# Inspect what prune would remove without applying
263+# (also lists every skipped candidate with its reason)
132264 gordon images prune --dry-run
133265
134266 # Skip confirmation prompt
M docs/cli/index.md +56 -124
@@ -1,8 +1,8 @@
11 # CLI Commands
22
3-Gordon provides a command-line interface for server management, deployment, and authentication.
3+Gordon provides a command-line interface for server management, app deployment, and authentication.
44
5-Most `list` commands also support `--json` for machine-readable output.
5+Most `list`/`show` commands also support `--json` for machine-readable output.
66
77 Commands are organized by where they run:
88
@@ -20,30 +20,15 @@ Commands are organized by where they run:
2020
2121 ## Management Commands (local or remote)
2222
23-Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.
23+Management commands use the authenticated daemon API. By default they connect through the owner-only local Unix socket; add `--remote` to use authenticated HTTP/TLS against another Gordon instance.
2424
2525 | Command | Description | Documentation |
2626 |---------|-------------|---------------|
27-| `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |
28-| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |
29-| `gordon backups` | Manage database backups | [backup](./backup.md) |
30-| `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |
31-| `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) |
34-| `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
35-| `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
36-| `gordon preview` | Create or manage preview environments | [preview](../config/preview.md) |
37-| `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
38-| `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) |
39-| `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
40-| `gordon restart` | Restart a running container | [restart](./restart.md) |
41-| `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) |
42-| `gordon routes` | Manage routes | [routes](./routes.md) |
43-| `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
44-| `gordon status` | Show Gordon server status | [status](./status.md) |
45-| `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
46-| `gordon volumes` | Manage volumes | - |
27+| `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
28+| `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
29+| `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) |
30+| `gordon images` | Push, list, and prune images | [images](./images.md) |
31+| `gordon volumes` | Manage volumes | [volumes](./volumes.md) |
4732
4833 ## Client Commands
4934
@@ -60,54 +45,59 @@ Management commands run locally through in-process services by default. Add `--r
6045 gordon serve
6146 gordon serve --config /path/to/config.toml
6247
63-# Reload configuration
64-gordon reload
65-
66-# Deploy a specific route
67-gordon deploy myapp.example.com
68-
69-# Restart a running container
70-gordon restart myapp.example.com
71-
72-# First-time route setup
73-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
74-
75-# Then push and deploy
76-gordon push myapp:latest --domain app.example.com --build --no-confirm
77-
78-# Push an image and deploy
79-gordon push myapp --build
80-
81-# Push and deploy without confirmation
82-gordon push myapp --no-confirm
83-
84-# Pin to a specific tag
85-gordon pin myapp.example.com
48+# Reload installation configuration (never activates app state)
49+gordon daemon reload
50+
51+# Applications (daemon-owned; fail without a reachable daemon)
52+gordon apps apply --file blog.toml
53+gordon apps apply --file blog.toml --deploy
54+gordon apps list
55+gordon apps show blog
56+gordon apps diff blog
57+gordon apps deploy blog
58+gordon apps restart blog
59+gordon apps stop blog
60+gordon apps start blog
61+gordon apps remove blog
62+gordon apps operations show blog --key <operation-key>
63+gordon apps operations watch blog --key <operation-key>
64+gordon apps secrets list blog
65+
66+# Daemon
67+gordon daemon status
68+gordon daemon config show
69+gordon daemon config validate --file /path/to/gordon.toml
70+gordon daemon tls
71+gordon daemon networks
72+
73+# Push an image (OCI transfer only; deploy separately)
74+gordon images push myapp --build --remote prod
8675
8776 # View logs
88-gordon logs # Gordon process logs
89-gordon logs -f # Follow process logs
90-gordon logs -n 100 # Last 100 lines
91-gordon logs myapp.example.com # Container logs for myapp.example.com
92-gordon logs myapp.example.com -f # Follow container logs
77+gordon daemon logs # Gordon process logs
78+gordon daemon logs -f # Follow process logs
79+gordon daemon logs -n 100 # Last 100 process-log lines
80+gordon apps logs blog --service web # App service logs
81+gordon apps logs blog --service web --follow # Follow app service logs
9382
9483 # Check version
9584 gordon version
9685
9786 # Traffic plane
98-gordon traffic status --remote prod
99-gordon traffic status --remote prod --json
87+gordon daemon traffic --remote prod
88+gordon daemon traffic --remote prod --json
10089
10190 # Backups
10291 gordon backups list
103-gordon backups run app.example.com
104-gordon backups detect app.example.com
92+gordon backups run shop --service api --database orders
93+gordon backups volume run shop --service api --volume data
10594 gordon backups status
10695
10796 # Images
97+gordon images push myapp --build --remote prod
10898 gordon images list
109-gordon images prune --runtime-only
110-gordon images prune --keep 3
99+gordon images prune --dry-run
100+gordon images prune --keep-releases 3
111101
112102 # Authentication
113103 gordon auth login --remote https://gordon.example.com --token $TOKEN
@@ -119,43 +109,14 @@ gordon auth token list
119109 gordon auth token revoke <token-id>
120110 gordon auth internal
121111
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-# Secrets
135-gordon secrets list myapp.example.com
136-gordon secrets set myapp.example.com DATABASE_URL "postgres://..."
137-gordon secrets remove myapp.example.com DATABASE_URL
138-
139112 # Remotes
140113 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
141114 gordon remotes list
142115 gordon remotes use prod
143116
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-
150117 # Volumes
151118 gordon volumes list
152119 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
159120 ```
160121
161122 ## Global Options
@@ -173,64 +134,35 @@ The CLI can target remote Gordon instances using client config, an active remote
173134 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
174135 when you want to bypass your saved configuration.
175136
176-When no explicit remote is selected and no active remote is configured, Gordon can now
177-**auto-infer a saved remote** for commands that already have a concrete target. It probes your
178-saved remotes and uses the remote automatically when exactly one matches. If multiple remotes
179-match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe
180-fails, Gordon also stops rather than guessing.
181-
182-Auto-inference currently applies to target-based commands such as:
183-- `gordon push`
184-- `gordon attachments push`
185-- `gordon deploy <domain>`
186-- `gordon restart <domain>`
187-- `gordon pin <domain>` / `gordon pin list <domain>`
188-- `gordon routes show <domain>` / `gordon routes remove <domain>`
189-- `gordon secrets list|set|remove <domain>`
190-- `gordon attachments list <target>` / `gordon attachments add|remove <target> ...`
191-- `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>`
192-- `gordon images tags <repository>`
193-- `gordon logs <domain>`
194-
195-`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
196-nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
197-one to force a single target.
137+When no explicit remote is selected and no active remote is configured, Gordon can
138+**auto-infer a saved remote** for `gordon images push`. It probes your saved remotes and uses the
139+remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
140+an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
141+stops rather than guessing.
198142
199143 ```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-
208144 # Auto-inferred single match from saved remotes
209-gordon push myapp --build
210-gordon deploy app.example.com
145+gordon images push myapp --build
211146 ```
212147
213148 **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.
215149
216150 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
217151 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`
218152 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.
221153
222154 ```bash
223155 # Using flags (use the gordon_domain from remote Gordon config)
224-gordon routes list --remote https://gordon.example.com --token $TOKEN
156+gordon daemon status --remote https://gordon.example.com --token $TOKEN
225157
226158 # Against self-signed/private CA endpoint
227-gordon --remote https://gordon.example.com --token $TOKEN --insecure status
159+gordon --remote https://gordon.example.com --token $TOKEN --insecure daemon status
228160
229161 # Using environment variables
230162 export GORDON_REMOTE=https://gordon.example.com
231163 export GORDON_TOKEN=$TOKEN
232164 export GORDON_INSECURE=true
233-gordon routes list
165+gordon daemon status
234166 ```
235167
236168 ## Exit Codes
D docs/cli/networks.md +0 -51
@@ -1,51 +0,0 @@
1-# Networks Commands
2-
3-Inspect Gordon-managed Docker networks.
4-
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7-
8-## gordon networks
9-
10-### Subcommands
11-
12-| Subcommand | Description |
13-|------------|-------------|
14-| `list` | List Gordon-managed Docker networks |
15-
16----
17-
18-## gordon networks list
19-
20-Display Docker networks managed by Gordon, including which containers
21-are connected to each network.
22-
23-```bash
24-gordon networks list
25-gordon networks list --json
26-gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
27-```
28-
29-### Flags
30-
31-| Flag | Description |
32-|------|-------------|
33-| `--json` | Output as JSON |
34-
35-### JSON Output
36-
37-```json
38-[
39- {
40- "name": "gordon_myapp",
41- "driver": "bridge",
42- "containers": ["container1", "container2"]
43- }
44-]
45-```
46-
47-## Related
48-
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)
D docs/cli/push.md +0 -151
@@ -1,151 +0,0 @@
1-# Push Command
2-
3-Tag, push, and optionally deploy an image to the Gordon registry.
4-
5-## gordon push
6-
7-### Synopsis
8-
9-```bash
10-gordon push [image] [options]
11-```
12-
13-### Arguments
14-
15-| Argument | Description |
16-|----------|-------------|
17-| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
18-
19-### Options
20-
21-| Option | Description |
22-|--------|-------------|
23-| `--build` | Build the image first using `docker buildx` |
24-| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
25-| `--platform` | Target platform for buildx (default: `linux/amd64`) |
26-| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27-| `--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) |
32-| `--token` | Authentication token for remote |
33-
34-### Description
35-
36-`gordon push` tags the selected image for the Gordon registry, pushes it, and
37-optionally deploys matching routes.
38-
39-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.
41-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 use
43-`--remote` explicitly.
44-
45-Image resolution order:
46-1. `--domain` is the explicit deploy target override for legacy workflows
47-2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup
48-3. Dotted positional refs probe image routes first, then fall back to legacy domain lookup
49-4. No-arg mode auto-detects from the Dockerfile label or current directory
50-
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,
58- 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.
60-- When `--build` is set, the command builds with `docker buildx build --load`
61- and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
62- environment plus any `--build-arg` values. To use these in your Dockerfile,
63- declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
64- in build steps.
65-- Use `-f/--file` to build from a Dockerfile outside the current directory root.
66-- The version tag and `latest` are both pushed (unless the version is `latest`).
67-
68-### Authentication
69-
70-When used with `--remote`, gordon push authenticates in two ways:
71-
72-- **Admin API** (route resolution, deploy): 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` required
74-
75-This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
76-
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-Detection
87-
88-Gordon reads version tags from CI environment variables (in priority order):
89-
90-| CI System | Variable | Example |
91-|-----------|----------|---------|
92-| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
93-| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
94-| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
95-| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
96-| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
97-| Fallback | - | `latest` |
98-
99-### Examples
100-
101-```bash
102-# Build, push, and deploy (auto-detect image name)
103-gordon push --build --remote https://gordon.example.com --no-confirm
104-
105-# Push an image and deploy it
106-gordon push myapp --remote https://gordon.example.com --no-confirm
107-
108-# Push and deploy to an explicit domain
109-gordon push myapp:latest --domain app.example.com --remote https://gordon.example.com --no-confirm
110-
111-# Tagged refs still resolve routes by image name
112-gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy
113-
114-# Push existing local image, skip deploy
115-gordon push myapp --tag v1.2.0 --no-deploy
116-
117-# Build for ARM and pass build args
118-gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
119-
120-# Build from a custom Dockerfile path
121-gordon push myapp --build -f docker/app/Dockerfile
122-
123-# Legacy compatibility: domain-looking positional target
124-gordon push app.example.com --no-confirm
125-
126-# CI/CD usage (single env var, no docker login needed)
127-export GORDON_TOKEN="your-token"
128-gordon push myapp --build --remote https://gordon.example.com --no-confirm
129-```
130-
131-### Notes
132-
133-- 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,
137- install the `docker-buildx-plugin` package.
138-- Gordon uses native registry uploads instead of shelling out to `docker push`.
139- Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
140- per-request limit so proxied pushes keep working. Keep the server's
141- `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
142- works out of the box.
143-
144-## Related
145-
146-- [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)
M docs/cli/remotes.md +15 -26
@@ -163,15 +163,13 @@ gordon remotes use <name>
163163 ### Description
164164
165165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166-`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
167166
168-If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for commands with a concrete target (for example `gordon push myapp`, `gordon deploy app.example.com`, or `gordon images tags myapp`). It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.
167+If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon images push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.
169168
170169 ```bash
171170 gordon remotes use prod
172-gordon secrets list app.com # Uses prod remote automatically
173-gordon routes list # Aggregates local + saved remotes
174-gordon routes status # Aggregates local + saved remotes
171+gordon apps list # Uses prod remote automatically
172+gordon daemon status # Uses prod remote automatically
175173 ```
176174
177175 ### Example
@@ -246,7 +244,7 @@ token = "eyJ..."
246244
247245 When multiple sources specify remote or token, the CLI uses this priority:
248246
249-For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
247+For aggregate views, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
250248 Auto-inference is not used for those aggregate views.
251249
252250 **Remote target selection:**
@@ -271,13 +269,8 @@ Auto-inference is not used for those aggregate views.
271269 This allows overriding specific values while keeping defaults:
272270
273271 ```bash
274-# Aggregate routes views
275-gordon routes list
276-gordon routes status
277-
278272 # Force one target
279-GORDON_REMOTE=prod gordon routes list
280-GORDON_REMOTE=prod gordon routes status
273+GORDON_REMOTE=prod gordon daemon status
281274 ```
282275
283276 ---
@@ -294,12 +287,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
294287
295288 # Work with prod
296289 gordon remotes use prod
297-gordon secrets list myapp.example.com
298-gordon routes list --remote prod
290+gordon apps list
291+gordon daemon status
299292
300293 # Switch to staging
301294 gordon remotes use staging
302-GORDON_REMOTE=staging gordon routes status
295+GORDON_REMOTE=staging gordon daemon status
303296 ```
304297
305298 ### CI/CD Pipeline
@@ -311,25 +304,21 @@ env:
311304 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
312305
313306 steps:
314- - name: Deploy to Gordon
307+ - name: Push to Gordon
315308 run: |
316- gordon routes deploy myapp.example.com
309+ gordon images push myapp --build --remote ${{ secrets.GORDON_URL }}
317310 ```
318311
319312 ### Compare Environments
320313
321314 ```bash
322-# Aggregate route inventory/status across local + saved remotes
323-gordon routes list
324-gordon routes status
325-
326315 # Compare one target at a time
327-gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
328-GORDON_REMOTE=staging gordon routes status
316+gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN
317+GORDON_REMOTE=staging gordon daemon status
329318
330319 # Or switch between active remotes for other commands
331-gordon remotes use prod && gordon secrets list myapp.example.com
332-gordon remotes use staging && gordon secrets list myapp.example.com
320+gordon remotes use prod && gordon apps list
321+gordon remotes use staging && gordon apps list
333322 ```
334323
335324 ### Private Admin + Wildcard App Domains
@@ -354,7 +343,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal
354343
355344 If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA.
356345
357-`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`.
346+`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: the reverse proxy can still serve wildcard app domains like `*.example.com`.
358347
359348 ## Migration from [client] Config
360349
D docs/cli/restart.md +0 -54
@@ -1,54 +0,0 @@
1-# Restart Command
2-
3-Restart a running container for a route.
4-
5-## gordon restart
6-
7-### Synopsis
8-
9-```bash
10-gordon restart <domain> [options]
11-```
12-
13-### Arguments
14-
15-| Argument | Description |
16-|----------|-------------|
17-| `<domain>` | The route domain to restart |
18-
19-### Options
20-
21-| Option | Description |
22-|--------|-------------|
23-| `--with-attachments` | Also restart attached services (databases, caches) |
24-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
25-| `--token` | Authentication token for remote |
26-
27-### Description
28-
29-Restarts the container for the specified route. This is useful after updating
30-environment variables or secrets without performing a full redeploy.
31-
32-When `--with-attachments` is set, Gordon also restarts any attachment containers
33-for the route.
34-
35-### Examples
36-
37-```bash
38-# Restart main container only
39-gordon restart myapp.example.com
40-
41-# Restart with attachments
42-gordon restart myapp.example.com --with-attachments
43-```
44-
45-### Notes
46-
47-- Local mode works when run on the Gordon host. It uses the same deploy-signal path as `gordon deploy <domain>`.
48-- `--with-attachments` is only supported in remote mode.
49-- In remote mode, target your Gordon admin endpoint (for example `https://gordon.example.com`), not the registry host (for example `https://reg.example.com`).
50-
51-## Related
52-
53-- [CLI Overview](./index.md)
54-- [Secrets Command](./secrets.md)
D docs/cli/routes.md +0 -442
@@ -1,442 +0,0 @@
1-# Routes Commands
2-
3-Manage routes on local or remote Gordon instances.
4-
5-Most remote-capable commands use client config or an active remote by default.
6-`gordon routes list` and `gordon routes status` are different: when neither
7-`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8-saved remote under its own heading. See [CLI Overview](./index.md).
9-
10-## gordon routes
11-
12-### Subcommands
13-
14-| Subcommand | Description |
15-|------------|-------------|
16-| `list` | List routes by domain and image |
17-| `status` | Show detailed route status |
18-| `show` | Show details for a single route |
19-| `add` | Create or update a route |
20-| `remove` | Remove a route |
21-| `deploy` | Deploy a specific route |
22-
23----
24-
25-## gordon routes list
26-
27-List routes by domain and image only.
28-
29-When no target is selected, Gordon shows local routes first, then each saved
30-remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31-target only.
32-
33-```bash
34-gordon routes list
35-gordon routes list --json
36-gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37-GORDON_REMOTE=prod gordon routes list
38-```
39-
40-### Output
41-
42-```text
43-Routes
44-
45-Local
46- app.example.com myapp:latest
47- api.example.com myapi:v2.1.0
48-
49-Remote: hetzner-vps
50- gordon.example.com gordon-webapp:latest
51-
52-Remote: igor
53- grafana.supri.xyz grafana
54- test.supri.xyz hello-test
55-```
56-
57-### JSON Output
58-
59-```bash
60-gordon routes list --json
61-```
62-
63-```json
64-[
65- {
66- "kind": "local",
67- "name": "local",
68- "routes": [
69- {
70- "domain": "app.example.com",
71- "image": "myapp:latest"
72- }
73- ]
74- },
75- {
76- "kind": "remote",
77- "name": "igor",
78- "url": "https://gordon.supri.xyz",
79- "routes": [
80- {
81- "domain": "grafana.supri.xyz",
82- "image": "grafana"
83- }
84- ]
85- }
86-]
87-```
88-
89-Single-target mode still returns a one-element array. Sections can also include
90-an `error` field when a target is unavailable.
91-
92-### Options
93-
94-| Option | Description |
95-|--------|-------------|
96-| `--json` | Output routes as JSON |
97-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98-| `--token` | Authentication token for remote |
99-
100----
101-
102-## gordon routes status
103-
104-Show detailed route status for each target.
105-
106-`routes status` uses the same target selection rules as `routes list`. When no
107-target is selected, Gordon shows local status first, then each saved remote
108-under its own heading.
109-
110-```bash
111-gordon routes status
112-gordon routes status --json
113-gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114-GORDON_REMOTE=prod gordon routes status
115-```
116-
117-### Output
118-
119-```text
120-Route Status
121-
122-Local
123- <rich tree for local routes>
124-
125-Remote: hetzner-vps
126- <rich tree for that remote>
127-
128-Remote: igor
129- <rich tree for that remote>
130-```
131-
132-The rich view keeps network grouping, container status, HTTP probe status, and
133-attachments within each target.
134-
135-### JSON Output
136-
137-```json
138-[
139- {
140- "kind": "local",
141- "name": "local",
142- "routes": [
143- {
144- "domain": "app.example.com",
145- "image": "myapp:latest",
146- "container_id": "abcd1234",
147- "container_status": "running",
148- "http_status": 200,
149- "network": "gordon-shared",
150- "attachments": [
151- {
152- "name": "postgres",
153- "image": "postgres:18",
154- "status": "running"
155- }
156- ]
157- }
158- ]
159- }
160-]
161-```
162-
163-Single-target mode still returns a one-element array. Sections can also include
164-an `error` field when a target is unavailable.
165-
166-### Options
167-
168-| Option | Description |
169-|--------|-------------|
170-| `--json` | Output routes as JSON |
171-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
172-| `--token` | Authentication token for remote |
173-
174----
175-
176-## gordon routes add
177-
178-Create a new route or update an existing route.
179-
180-```bash
181-gordon routes add <domain> <image>
182-gordon routes add myapp.example.com myapp:latest
183-```
184-
185-If the route already exists, Gordon updates it to the new image instead of failing.
186-The image does not need to be pushed to the Gordon registry before you add the route.
187-
188-### Arguments
189-
190-| Argument | Description |
191-|----------|-------------|
192-| `<domain>` | The domain name for the route |
193-| `<image>` | The container image to deploy |
194-
195-### Options
196-
197-| Option | Description |
198-|--------|-------------|
199-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
200-| `--token` | Authentication token for remote |
201-
202-### Examples
203-
204-```bash
205-# Local
206-gordon routes add myapp.example.com myapp:latest
207-gordon routes add api.example.com myapi:v2.1.0
208-
209-# Update an existing route
210-gordon routes add myapp.example.com myapp:v2
211-
212-# Remote (override)
213-gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
214-```
215-
216-### Notes
217-
218-- `gordon routes add` is idempotent: it creates the route when missing and updates it when present.
219-- You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image.
220-
221----
222-
223-## gordon routes show
224-
225-Show detailed information about a single route.
226-
227-```bash
228-gordon routes show <domain>
229-gordon routes show myapp.example.com
230-gordon routes show myapp.example.com --json
231-```
232-
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `<domain>` | The domain name of the route to inspect |
238-
239-### Options
240-
241-| Option | Description |
242-|--------|-------------|
243-| `--json` | Output route details as JSON |
244-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
245-| `--token` | Authentication token for remote |
246-
247-### Description
248-
249-Displays the configured image for the route plus any available container and HTTP health information.
250-In local-only mode, health data may be unavailable.
251-
252-### Examples
253-
254-```bash
255-# Local
256-gordon routes show myapp.example.com
257-
258-# JSON
259-gordon routes show myapp.example.com --json
260-
261-# Remote (override)
262-gordon routes show myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
263-```
264-
265-### JSON Output
266-
267-```json
268-{
269- "domain": "myapp.example.com",
270- "image": "myapp:latest",
271- "container_status": "running",
272- "http_status": 200
273-}
274-```
275-
276----
277-
278-## gordon routes diagnose
279-
280-Diagnose route configuration, runtime state, preserved volumes, and orphaned attachment containers.
281-
282-```bash
283-gordon routes diagnose <domain>
284-gordon routes diagnose myapp.example.com --json
285-```
286-
287-Use this after route deletion or failed deployment to see whether runtime state still exists and which safe cleanup commands to run next.
288-
289-### Output
290-
291-```text
292-Route diagnosis: myapp.example.com
293-Image: myapp:latest
294-Container: running abc123def456
295-Preserved volume: gordon-myapp-example-com-data
296-Orphaned attachment: postgres
297-Hint: persistent volumes are preserved by default
298-Hint: run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes
299-```
300-
301-### JSON Output
302-
303-```json
304-{
305- "domain": "myapp.example.com",
306- "configured": true,
307- "route": {"domain": "myapp.example.com", "image": "myapp:latest", "https": true},
308- "runtime": {"domain": "myapp.example.com", "container_id": "abc123def456", "container_status": "running"},
309- "volumes": [{"name": "gordon-myapp-example-com-data", "in_use": false}],
310- "orphaned_attachments": [{"container_id": "pg123", "name": "postgres", "image": "postgres:16", "status": "running", "owner": "myapp.example.com"}],
311- "hints": ["persistent volumes are preserved by default"]
312-}
313-```
314-
315-### Next Steps
316-
317-```bash
318-gordon routes purge myapp.example.com # dry-run retained resources
319-gordon routes purge myapp.example.com --attachments --force
320-```
321-
322----
323-
324-## gordon routes purge
325-
326-Review retained resources for a route and execute supported explicit cleanup actions only when forced.
327-
328-```bash
329-gordon routes purge myapp.example.com # dry-run
330-gordon routes purge myapp.example.com --attachments --force
331-gordon routes purge myapp.example.com --volumes # report preserved volumes for manual review
332-```
333-
334-### Dry-run Output
335-
336-```text
337-Purge dry-run: myapp.example.com
338-Preserved attachments:
339- postgres (running)
340-Purge candidate: route_container abc123def456
341-Preserved volume: gordon-myapp-example-com-data
342-Hint: dry-run only; add --force with explicit category flags to execute supported purge actions
343-```
344-
345-### Forced Attachment Cleanup Output
346-
347-```text
348-Purge completed: myapp.example.com
349-Removed containers:
350- postgres
351-Hint: attachment volumes and data were preserved
352-```
353-
354-Purge is intentionally conservative. Volumes are reported and preserved unless a dedicated volume deletion flow supports safe targeted deletion.
355-
356----
357-
358-## gordon routes remove
359-
360-Remove a route.
361-
362-Route removal is safe by default. Gordon removes the route from configuration and reconciles the active route container so it is no longer served or restarted by Gordon's monitor. Stateful data is preserved. Volumes and attachment data are not deleted unless a separate explicit purge flow requests destructive cleanup.
363-
364-```bash
365-gordon routes remove <domain>
366-gordon routes remove myapp.example.com
367-```
368-
369-### Arguments
370-
371-| Argument | Description |
372-|----------|-------------|
373-| `<domain>` | The domain name of the route to remove |
374-
375-### Options
376-
377-| Option | Description |
378-|--------|-------------|
379-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
380-| `--token` | Authentication token for remote |
381-
382-### Examples
383-
384-```bash
385-# Local
386-gordon routes remove myapp.example.com
387-
388-# Remote (override)
389-gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
390-
391-# Typical output
392-Route removed: myapp.example.com
393-
394-Removed containers:
395- gordon-myapp.example.com (abc123def456)
396-
397-Preserved attachments:
398- postgres (running)
399-```
400-
401----
402-
403-## gordon routes deploy
404-
405-Deploy or redeploy a specific route.
406-
407-```bash
408-gordon routes deploy <domain>
409-gordon routes deploy myapp.example.com
410-```
411-
412-### Arguments
413-
414-| Argument | Description |
415-|----------|-------------|
416-| `<domain>` | The domain name of the route to deploy |
417-
418-### Options
419-
420-| Option | Description |
421-|--------|-------------|
422-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
423-| `--token` | Authentication token for remote |
424-
425-### Description
426-
427-Triggers a fresh image pull and container redeployment for the specified route.
428-
429-### Examples
430-
431-```bash
432-# Local
433-gordon routes deploy myapp.example.com
434-
435-# Remote (override)
436-gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
437-```
438-
439-## Related
440-
441-- [CLI Overview](./index.md)
442-- [Routes Configuration](../config/routes.md)
D docs/cli/secrets.md +0 -251
@@ -1,251 +0,0 @@
1-# Secrets Commands
2-
3-Manage secrets on local or remote Gordon instances.
4-
5-Remote targeting uses client config or an active remote by default.
6-When you provide a concrete domain and no remote is selected, Gordon can also
7-auto-infer a saved remote when exactly one match is found.
8-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
9-
10-Storage depends on the secrets backend:
11-- `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
12-- `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
13-- `unsafe`: secrets are stored in plain-text domain `.env` files
14-
15-## gordon secrets
16-
17-### Subcommands
18-
19-| Subcommand | Description |
20-|------------|-------------|
21-| `list` | List all secrets for a domain |
22-| `set` | Set a secret value |
23-| `remove` | Remove a secret |
24-
25----
26-
27-## gordon secrets list
28-
29-List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets.
30-
31-```bash
32-gordon secrets list <domain>
33-```
34-
35-### Arguments
36-
37-| Argument | Description |
38-|----------|-------------|
39-| `<domain>` | The domain name to list secrets for |
40-
41-### Options
42-
43-| Option | Description |
44-|--------|-------------|
45-| `--json` | Output secrets as JSON |
46-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
47-| `--token` | Authentication token for remote |
48-
49-### Examples
50-
51-```bash
52-# Local
53-gordon secrets list myapp.example.com
54-
55-# Remote (override)
56-gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
57-```
58-
59-### Output
60-
61-```
62-Secrets for app.mydomain.com
63-
64-Key Value
65-DATABASE_URL ****
66-API_KEY ****
67-├─ [postgres]
68-│ ├─ POSTGRES_USER ****
69-│ └─ POSTGRES_PASSWORD ****
70-└─ [redis]
71- └─ REDIS_PASSWORD ****
72-```
73-
74-### JSON Output
75-
76-```bash
77-gordon secrets list app.mydomain.com --json
78-```
79-
80-```json
81-{
82- "domain": "app.mydomain.com",
83- "secrets": {
84- "DATABASE_URL": "****",
85- "API_KEY": "****"
86- },
87- "attachments": {
88- "postgres": {
89- "POSTGRES_USER": "****",
90- "POSTGRES_PASSWORD": "****"
91- },
92- "redis": {
93- "REDIS_PASSWORD": "****"
94- }
95- }
96-}
97-```
98-
99----
100-
101-## gordon secrets set
102-
103-Set a secret value for a domain.
104-
105-```bash
106-gordon secrets set <domain> <KEY=value>...
107-gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
108-```
109-
110-### Arguments
111-
112-| Argument | Description |
113-|----------|-------------|
114-| `<domain>` | The domain name to set the secret for |
115-| `<KEY=value>...` | One or more secret key/value pairs |
116-
117-### Options
118-
119-| Option | Description |
120-|--------|-------------|
121-| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
122-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
123-| `--token` | Authentication token for remote |
124-
125-### Examples
126-
127-```bash
128-# Local
129-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db"
130-gordon secrets set myapp.example.com API_KEY="your-api-key"
131-
132-# Remote (override)
133-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN
134-
135-# Set attachment secrets
136-gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
137-gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret
138-
139-# Multiple secrets at once
140-gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
141-```
142-
143----
144-
145-## gordon secrets remove
146-
147-Remove a secret from a domain.
148-
149-```bash
150-gordon secrets remove <domain> <key>
151-```
152-
153-### Arguments
154-
155-| Argument | Description |
156-|----------|-------------|
157-| `<domain>` | The domain name |
158-| `<key>` | The secret key to remove |
159-
160-### Options
161-
162-| Option | Description |
163-|--------|-------------|
164-| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
165-| `--force` | Remove without confirmation |
166-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
167-| `--token` | Authentication token for remote |
168-
169-### Examples
170-
171-```bash
172-# Local
173-gordon secrets remove myapp.example.com DATABASE_URL
174-
175-# Remote (override)
176-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-```
181-
182----
183-
184-## Workflow Examples
185-
186-### Setting Up Application Secrets
187-
188-```bash
189-# Database connection
190-gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/mydb"
191-
192-# API keys
193-gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..."
194-gordon secrets set myapp.example.com SENDGRID_KEY="SG..."
195-
196-# JWT secret
197-gordon secrets set myapp.example.com JWT_SECRET="your-jwt-secret-here"
198-
199-# Verify
200-gordon secrets list myapp.example.com
201-```
202-
203-### CI/CD Secret Management
204-
205-```bash
206-# In your CI/CD pipeline
207-export GORDON_REMOTE=https://gordon.mydomain.com
208-export GORDON_TOKEN=$GORDON_TOKEN
209-
210-# Update secrets before deploy
211-gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL"
212-gordon secrets set myapp.example.com API_KEY="$API_KEY"
213-
214-# Deploy
215-gordon deploy myapp.example.com
216-```
217-
218-### Rotating Secrets
219-
220-```bash
221-# Generate new secret
222-NEW_JWT_SECRET=$(openssl rand -base64 32)
223-
224-# Update the secret
225-gordon secrets set myapp.example.com JWT_SECRET="$NEW_JWT_SECRET"
226-
227-# Redeploy to pick up new secret
228-gordon deploy myapp.example.com
229-```
230-
231-### Attachment Secrets
232-
233-```bash
234-# Configure database credentials
235-gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
236-
237-# Configure cache credentials
238-gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret
239-
240-# Verify
241-gordon secrets list app.mydomain.com
242-
243-# Redeploy to pick up new secrets
244-gordon deploy app.mydomain.com
245-```
246-
247-## Related
248-
249-- [CLI Overview](./index.md)
250-- [Secrets Configuration](../config/secrets.md)
251-- [Environment Variables](../config/env.md)
M docs/cli/serve.md +8 -171
@@ -23,8 +23,8 @@ gordon serve [options]
2323 Starts the Gordon server, which includes:
2424
2525 - **Container Registry** - Receives image pushes on the registry port
26-- **HTTP Proxy** - Routes traffic to containers on the proxy port
27-- **Event Bus** - Coordinates deployments and updates
26+- **Reverse Proxy** - Routes app hosts to recorded loopback backends
27+- **Event Bus** - Coordinates runtime notifications
2828 - **Config Watcher** - Monitors configuration file for changes
2929
3030 ### Configuration File Detection
@@ -68,8 +68,8 @@ Gordon responds to these signals:
6868 |--------|--------|
6969 | `SIGTERM` | Graceful shutdown |
7070 | `SIGINT` | Graceful shutdown (Ctrl+C) |
71-| `SIGUSR1` | Reload configuration |
72-| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |
71+| `SIGUSR1` | Reload installation configuration |
72+| `SIGUSR2` | Reserved; no app deployment action |
7373
7474 ### Running with systemd
7575
@@ -106,21 +106,16 @@ sudo loginctl enable-linger $USER
106106 7. Register event handlers
107107 8. Start config file watcher
108108 9. Start HTTP servers
109-10. Run best-effort startup recovery (sync existing containers, then recover configured routes)
109+10. Run best-effort startup recovery (reconcile apps intended to run from active state)
110110
111111 ### Startup Recovery
112112
113113 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.
114114
115-1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
116-2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.
117-3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
115+1. **Reconcile app boot state** - Gordon ensures apps intended to run are running from active state (stopped-intent apps stay stopped, pending desired revisions are never activated).
116+4. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
118117
119-This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.
120-
121-Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes.
122-
123-Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
118+Reload and startup recovery never activate pending desired app state, re-resolve images, or flip intent. They touch installation settings and runtime reconciliation only.
124119
125120 ### Shutdown Sequence
126121
@@ -133,164 +128,6 @@ Startup recovery is intentionally narrower than a manual deploy. It only starts
133128
134129 ---
135130
136-## gordon reload
137-
138-Reload configuration and sync containers to match.
139-
140-### Synopsis
141-
142-```bash
143-gordon reload
144-```
145-
146-### Description
147-
148-Sends `SIGUSR1` to the running Gordon process, triggering:
149-
150-- Configuration file reload
151-- Route synchronization
152-- Deployment of containers for routes missing containers
153-- Attachment deployment
154-
155-### Example
156-
157-```bash
158-# After editing gordon.toml, apply changes without restart
159-vim ~/.config/gordon/gordon.toml
160-gordon reload
161-```
162-
163----
164-
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 logs
224-
225-Display Gordon process logs or container logs.
226-
227-### Synopsis
228-
229-```bash
230-gordon logs [domain] [options]
231-```
232-
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
238-
239-### Options
240-
241-| Option | Short | Default | Description |
242-|--------|-------|---------|-------------|
243-| `--config` | `-c` | Auto | Path to config file |
244-| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
245-| `--lines` | `-n` | 50 | Number of lines to show |
246-| `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
247-| `--token` | | | Authentication token for remote |
248-
249-Remote targeting uses client config or an active remote by default.
250-When you provide a concrete domain and no remote is selected, Gordon can also
251-auto-infer a saved remote when exactly one match is found.
252-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
253-
254-Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
255-
256-### Examples
257-
258-```bash
259-# Gordon process logs
260-gordon logs # Last 50 lines
261-gordon logs -f # Follow logs
262-gordon logs -n 100 # Last 100 lines
263-gordon logs -f -n 200 # Follow, starting from last 200 lines
264-
265-# Container logs
266-gordon logs myapp.example.com # Last 50 lines from container
267-gordon logs myapp.example.com -f # Follow container logs
268-gordon logs myapp.example.com -n 100 # Last 100 lines from container
269-
270-# Remote mode (override)
271-gordon logs --remote https://gordon.mydomain.com --token $TOKEN
272-gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
273-```
274-
275-### Log Locations
276-
277-```bash
278-# Using gordon logs
279-gordon logs -f
280-
281-# Direct file access
282-tail -f ~/.gordon/logs/gordon.log
283-
284-# With systemd
285-journalctl --user -u gordon -f
286-
287-# Container logs via docker (local alternative)
288-docker logs --tail 50 myapp.example.com
289-docker logs -f myapp.example.com
290-```
291-
292----
293-
294131 ## gordon version
295132
296133 Print version information.
D docs/cli/status.md +0 -113
@@ -1,113 +0,0 @@
1-# Status Command
2-
3-Show Gordon server status and container health.
4-
5-## gordon status
6-
7-Display server configuration and container status for all routes.
8-
9-```bash
10-gordon status
11-gordon status --remote https://gordon.mydomain.com --token $TOKEN
12-```
13-
14-`gordon status` works in local mode and remote mode.
15-
16-- Local mode reads status from in-process services.
17-- Remote mode reads status from the target admin API.
18-
19-### Output
20-
21-```
22-Gordon Status
23-
24-Gordon Domain: gordon.example.com
25-Registry Port: 5000
26-Server Port: 8088
27-Routes: 3
28-Auto-Route: true
29-Network Isolation: false
30-
31-Container Status:
32- app.example.com: running
33- api.example.com: running
34- worker.example.com: stopped
35-```
36-
37-### Information Displayed
38-
39-| Field | Description |
40-|-------|-------------|
41-| Gordon Domain | Public Gordon domain from configuration |
42-| Registry Port | Docker registry port |
43-| Server Port | Gordon HTTP proxy port |
44-| Routes | Total configured routes |
45-| Auto-Route | Whether auto-routing is enabled |
46-| Network Isolation | Whether network isolation is enabled |
47-| Container Status | Status of each route's container |
48-
49-### Container States
50-
51-| State | Description |
52-|-------|-------------|
53-| running | Container is running and healthy |
54-| restarting | Container is in runtime restart backoff or restart cycle |
55-| stopped | Container was stopped |
56-| exited | Container exited (check logs for errors) |
57-| paused | Container is paused |
58-| unknown | Unable to determine container state |
59-
60-## Flags
61-
62-The status command uses global flags for remote access:
63-
64-| Flag | Description |
65-|------|-------------|
66-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
67-| `--token` | Authentication token |
68-
69-## Environment Variables
70-
71-| Variable | Description |
72-|----------|-------------|
73-| `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
74-| `GORDON_TOKEN` | Authentication token |
75-
76-## Examples
77-
78-### Check Local or Remote Status
79-
80-```bash
81-# Local
82-gordon status
83-
84-# Using flags
85-gordon status --remote https://gordon.mydomain.com --token $TOKEN
86-
87-# Using environment variables
88-export GORDON_REMOTE=https://gordon.mydomain.com
89-export GORDON_TOKEN=your-token
90-gordon status
91-```
92-
93-### Quick Health Check
94-
95-```bash
96-# Check if all containers are running
97-gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"
98-```
99-
100-## Required Permissions (Remote Only)
101-
102-Remote status calls require `admin:status:read` scope in the authentication token.
103-
104-```bash
105-# Generate token with required scope
106-gordon auth token generate --subject admin --scopes admin:status:read
107-```
108-
109-## Related
110-
111-- [Serve Command](./serve.md)
112-- [Routes Command](./routes.md)
113-- [Remote CLI Management](/wiki/guides/remote-cli.md)
D docs/cli/tls.md +0 -114
@@ -1,114 +0,0 @@
1-# TLS Commands
2-
3-Inspect public TLS/ACME certificate status.
4-
5-Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA.
6-
7-ACME challenge notes:
8-
9-- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
10-- HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated.
11-- TLS-ALPN-01 is not supported.
12-
13-Remote targeting uses client config or an active remote by default.
14-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
15-
16-## gordon tls
17-
18-### Subcommands
19-
20-| Subcommand | Description |
21-|------------|-------------|
22-| `status` | Show public TLS certificate status |
23-
24----
25-
26-## gordon tls status
27-
28-Display the current public TLS/ACME certificate status, including ACME mode,
29-certificate details, route coverage, and any errors.
30-
31-```bash
32-gordon tls status
33-gordon tls status --json
34-gordon tls status --remote https://gordon.mydomain.com --token $TOKEN
35-```
36-
37-### Flags
38-
39-| Flag | Description |
40-|------|-------------|
41-| `--json` | Output as JSON |
42-
43-### Human Output
44-
45-```text
46-Public TLS / ACME Status
47-
48-ACME: enabled
49-Configured Mode: auto
50-Effective Mode: http-01
51-Reason: configured
52-Token Source: env
53-
54-Certificates
55- ID: cert-abc123
56- Names: example.com, www.example.com
57- Status: valid
58- Not After: 2026-05-29 12:00:00
59-
60-Route Coverage
61- example.com covered=yes covered_by=cert-abc123
62- internal.local covered=no error=self-signed cert
63-
64-Errors
65- route internal.local has no ACME cert
66-```
67-
68-### JSON Output
69-
70-```json
71-{
72- "acme_enabled": true,
73- "configured_mode": "auto",
74- "effective_mode": "http-01",
75- "selection_reason": "configured",
76- "token_source": "env",
77- "certificates": [
78- {
79- "id": "cert-abc123",
80- "names": ["example.com", "www.example.com"],
81- "challenge": "http-01",
82- "status": "valid",
83- "not_after": "2026-05-29T12:00:00Z",
84- "renewal_pending": false
85- }
86- ],
87- "routes": [
88- {
89- "domain": "example.com",
90- "covered": true,
91- "covered_by": "cert-abc123",
92- "required_acme": true
93- },
94- {
95- "domain": "internal.local",
96- "covered": false,
97- "required_acme": false,
98- "error": "self-signed cert"
99- }
100- ],
101- "errors": ["route internal.local has no ACME cert"]
102-}
103-```
104-
105-### Token Source
106-
107-The `token_source` field indicates where the ACME token was sourced from
108-(e.g., `env`, `file`, `config`). The token value is never displayed.
109-
110-## Related
111-
112-- [CLI Overview](./index.md)
113-- [Status Command](./status.md)
114-- [Routes Command](./routes.md)
D docs/cli/traffic.md +0 -93
@@ -1,93 +0,0 @@
1-# gordon traffic
2-
3-Inspect Gordon traffic plane status.
4-
5-## Subcommands
6-
7-| Command | Description |
8-|---------|-------------|
9-| `gordon traffic status` | Show traffic entrypoints, counters, and reload status |
10-
11-## Usage
12-
13-```bash
14-gordon traffic status --remote prod
15-gordon traffic status --remote https://gordon.example.com --json
16-```
17-
18-Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status.
19-
20-## Flags
21-
22-| Flag | Description |
23-|------|-------------|
24-| `--json` | Output machine-readable JSON |
25-| `--remote`, `-r` | Remote Gordon instance or saved remote name |
26-
27-## JSON Output
28-
29-```json
30-{
31- "last_reload_status": "ok",
32- "entrypoints": [
33- {
34- "name": "postgres",
35- "address": "0.0.0.0:5432",
36- "protocol": "tcp",
37- "active": true,
38- "active_tcp_connections": 1,
39- "active_udp_sessions": 0,
40- "total_accepted": 12,
41- "total_refused": 0,
42- "total_errors": 0,
43- "bytes_in": 4096,
44- "bytes_out": 8192,
45- "smart_tcp": {
46- "http_accepted": 0,
47- "h2c_accepted": 0,
48- "https_fallback_accepted": 0,
49- "tls_passthrough_accepted": 0,
50- "raw_fallback_accepted": 0,
51- "entrypoint_cidr_refused": 0,
52- "raw_fallback_cidr_refused": 0,
53- "proxy_refused": 0,
54- "unknown_no_fallback_refused": 0,
55- "malformed_rejected": 0,
56- "sniff_timeout": 0,
57- "client_hello_too_large": 0
58- }
59- }
60- ],
61- "routers": [],
62- "services": [],
63- "counters": {
64- "active_tcp_connections": 1,
65- "active_udp_sessions": 0,
66- "total_accepted": 12,
67- "total_refused": 0,
68- "total_errors": 0,
69- "bytes_in": 4096,
70- "bytes_out": 8192,
71- "smart_tcp": {
72- "http_accepted": 0,
73- "h2c_accepted": 0,
74- "https_fallback_accepted": 0,
75- "tls_passthrough_accepted": 0,
76- "raw_fallback_accepted": 0,
77- "entrypoint_cidr_refused": 0,
78- "raw_fallback_cidr_refused": 0,
79- "proxy_refused": 0,
80- "unknown_no_fallback_refused": 0,
81- "malformed_rejected": 0,
82- "sniff_timeout": 0,
83- "client_hello_too_large": 0
84- }
85- }
86-}
87-```
88-
89-## Related
90-
91-- [Traffic configuration](../config/traffic.md)
92-- [Standalone services](../config/services.md)
93-- [Server status](./status.md)
M docs/cli/volumes.md +14 -2
@@ -19,7 +19,7 @@ gordon volumes list --json
1919
2020 ### `gordon volumes prune`
2121
22-Remove orphaned volumes no longer associated with a running container.
22+Remove app-owned volumes that were explicitly released and are no longer used by any container.
2323
2424 ```bash
2525 gordon volumes prune
@@ -29,10 +29,22 @@ gordon volumes prune --no-confirm
2929
3030 | Flag | Description |
3131 |------|-------------|
32-| `--dry-run` | Show what would be removed without deleting |
32+| `--dry-run` | Report the plan without deleting |
3333 | `--no-confirm` | Skip confirmation prompt |
3434 | `--json` | Output as JSON |
3535
36+#### What survives
37+
38+A volume is removed only when all of the following hold:
39+
40+- A durable ownership record explicitly marks it `released`.
41+- The runtime labels agree with that record: the app, the app incarnation UUID, and the service.
42+- No container mounts it.
43+
44+Everything else survives. Attached and retained volumes, volumes carrying only `gordon.managed`, volumes with no Gordon provenance, and volumes whose ownership cannot be established all survive. Ownership is never inferred from the volume name, and labels alone never make a volume deletable.
45+
46+With current metadata no lifecycle marks a volume `released`, so a volume prune normally reports a zero-deletion success. The command still lists every candidate with its verdict and reason, and `--json` returns the same plan shape as `gordon images prune`.
47+
3648 ## Related
3749
3850 - [Volumes Configuration](../config/volumes.md)
M docs/concepts.md +101 -184
@@ -9,276 +9,193 @@ Your development machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2
99 Gordon flips the typical deployment model:
1010
1111 1. **Build locally** where you have computing power
12-2. **Push the finished image** to your VPS
13-3. **Gordon deploys automatically**
12+2. **Push the finished image** to your VPS registry
13+3. **Apply the app manifest** declaring the image
14+4. **Deploy** to activate it
1415
1516 This means faster builds, less VPS resource usage, and a simpler deployment workflow.
1617
17-## Push-to-Deploy
18+## Push, Apply, Deploy
1819
19-Gordon combines a Docker registry with automatic deployment:
20+Gordon combines a Docker registry with a declarative app runtime:
2021
2122 ```
2223 ┌──────────────┐ push ┌──────────────┐
2324 │ docker build │ ──────────────>│ Gordon │
24-│ docker push │ │ Registry │
25-└──────────────┘ └──────┬───────┘
25+│ docker push │ (OCI only, │ Registry │
26+└──────────────┘ never deploys)└──────┬───────┘
2627 │
27- │ event: image.pushed
28+ ┌───────────────────┴───────────────────┐
29+ │ gordon apps apply --file blog.toml │ desired state
30+ │ gordon apps deploy blog │ activation
31+ └───────────────────┬───────────────────┘
2832 v
2933 ┌──────────────┐
30- │ Deploy │
31- │ Container │
34+ │ App │
35+ │ Containers │
3236 └──────────────┘
3337 ```
3438
35-When you push an image, Gordon:
39+Pushing an image only stores it. Nothing runs, no route is created, no manifest is modified. Activation is always an explicit `gordon apps deploy`.
3640
37-1. Stores the image in its registry
38-2. Fires an `image.pushed` event
39-3. Looks up the route for that image
40-4. Deploys a new container
41-5. Updates the proxy routing
42-6. Stops the old container
41+## Declarative Apps
4342
44-## Zero-Downtime Updates
43+One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities.
4544
46-Gordon ensures your app stays available during updates:
47-
48-1. **New container starts** while old container is still running
49-2. **Health check** waits for new container to be ready
50-3. **Traffic switches** to the new container
51-4. **Old container stops** after traffic has moved
52-
53-```
54-Time ─────────────────────────────────────────────>
55-
56-Old Container: [═══════════════════]
57- ↓ stop
58-New Container: [═════════════════════════>
59- ↑ start ↑ traffic routed
60-```
61-
62-## Deletion and Cleanup Lifecycle
45+```toml
46+name = "blog"
6347
64-Gordon separates configuration removal from destructive data cleanup:
48+[env]
49+APP_ENV = "production" # app-wide public env, injected into all services
6550
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.
51+[services.web]
52+image = "gordon.mydomain.com/blog:1.4.2"
7253
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.
54+[[services.web.http]]
55+host = "blog.mydomain.com"
56+port = 3000
7457
75-Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time.
58+[services.web.secrets] # ENV name -> secret name (values stay in pass)
59+DATABASE_URL = "database-url"
60+```
7661
77-Current runtime cleanup capabilities are intentionally conservative:
62+- `apps apply --file FILE` validates and persists desired configuration only.
63+- `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply.
64+- `--dry-run` validates and previews without persistence or runtime effects.
65+- Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content.
66+- The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential.
67+- Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command.
7868
79-- Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`.
80-- Gordon can stop and remove containers.
81-- Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category.
82-- Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added.
69+## Updates
8370
84-## Routes
71+Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time.
8572
86-Routes map domains to container images:
73+Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container.
8774
88-```toml
89-[routes]
90-"app.example.com" = { image = "myapp:latest" }
91-"api.example.com" = { image = "myapi:v2.1.0" }
75+```
76+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
9277 ```
9378
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.
95-
96-When a request comes in for `app.example.com`, Gordon:
79+If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed.
9780
98-1. Looks up the route configuration
99-2. Finds the running container for `myapp:latest`
100-3. Proxies the request to that container
81+Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data.
10182
102-### Route Domains
83+A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed.
10384
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.
85+Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported `unchanged`.
10586
106-```toml
107-[routes]
108-"dev-app.example.com" = { image = "internal-app:latest", https = false }
109-```
87+`gordon apps restart` restarts the same container and applies nothing: it withdraws traffic, restarts the pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned `ACTIVE` digest and publishes it.
11088
111-## Network Isolation
112-
113-Each app runs in its own isolated Docker network:
89+## Deletion and Cleanup Lifecycle
11490
115-```
116-┌────────────────────────────────────────────────┐
117-│ gordon-app-mydomain-com │
118-│ │
119-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
120-│ │ App │───>│ Postgres │ │ Redis │ │
121-│ │ :3000 │ │ :5432 │ │ :6379 │ │
122-│ └──────────┘ └──────────┘ └──────────┘ │
123-│ │
124-└────────────────────────────────────────────────┘
125-```
91+Gordon separates workload removal from destructive data cleanup:
12692
127-Benefits:
93+- **desired** — the persisted manifest revision waiting to be activated.
94+- **active** — the pinned, running definition (never inferred from containers).
95+- **stopped** — durable stopped intent: workloads are down, data preserved, reboot keeps them stopped.
96+- **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.
12897
129-- Containers can't access each other's services
130-- Services are only accessible by name within their network
131-- No port conflicts between apps
98+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.
13299
133-## Attachments
100+## App HTTP Hosts
134101
135-Attachments are service dependencies for your apps:
102+An app's HTTP interfaces declare the hosts Gordon serves:
136103
137104 ```toml
138-[attachments]
139-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
105+[[services.web.http]]
106+host = "app.example.com"
107+port = 3000
140108 ```
141109
142-Gordon deploys attachments to the same network as your app. Services are accessible by their image name:
110+When a request comes in for `app.example.com`, Gordon:
143111
144-```javascript
145-// In your app
146-const db = await connect("postgresql://postgres:5432/mydb");
147-const cache = await connect("redis://redis:6379");
148-```
112+1. Looks up the host in the ACTIVE projection (merged with installation external routes)
113+2. Finds the recorded loopback backend for that host (never a container IP — rootless-first)
114+3. Proxies the request to that backend
149115
150-## Network Groups
116+Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`).
151117
152-Network groups allow multiple apps to share services:
118+## Networks
153119
154-```toml
155-[network_groups]
156-"backend" = ["app.mydomain.com", "api.mydomain.com"]
120+Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership:
157121
158-[attachments]
159-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
122+```toml
123+[[network.shared]]
124+network = "backend"
125+services = ["web", "worker"]
160126 ```
161127
162-Both `app.mydomain.com` and `api.mydomain.com` can access the shared services.
128+Deploy adds AND removes memberships without disconnecting unrelated services. Short DNS names resolve privately; app-qualified aliases apply on shared networks.
163129
164130 ## Volumes
165131
166-Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives:
132+Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes:
167133
168-```dockerfile
169-FROM postgres:18
170-VOLUME ["/var/lib/postgresql/data"]
134+```toml
135+[[services.web.volume]]
136+name = "web-data"
137+path = "/data"
171138 ```
172139
173-Volume behavior:
140+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.
174141
175-- **auto_create**: Volumes are created automatically (default: true)
176-- **prefix**: Volume names are prefixed with `gordon-` (configurable)
177-- **preserve**: Volumes persist across container updates (default: true)
142+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.
178143
179-## Environment Variables
144+## Environment and Secrets
180145
181-Gordon loads environment variables from files based on the domain:
146+App-wide public env is declared in the manifest:
182147
148+```toml
149+[env]
150+APP_ENV = "production"
183151 ```
184-~/.gordon/env/
185-├── app_mydomain_com.env
186-├── api_mydomain_com.env
187-└── admin_mydomain_com.env
188-```
189-
190-Domain dots become underscores: `app.mydomain.com` → `app_mydomain_com.env`
191-
192-Variables are merged in order:
193-
194-1. Dockerfile `ENV` directives (lowest priority)
195-2. `.env` file values (highest priority)
196152
197-### Secret Providers
153+Service-specific values use `secrets` even when non-confidential:
198154
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}
155+```toml
156+[services.web.secrets]
157+DATABASE_URL = "database-url"
207158 ```
208159
209-## Configuration Hot-Reload
210-
211-Gordon watches its config file and reloads automatically:
160+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. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. Write values with `gordon apps secrets set`; only key names are ever echoed back, never values.
212161
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
162+## Installation Reload
217163
218-Route file edits now reload automatically again.
219-
220-You can also trigger a reload:
164+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.
221165
222166 ```bash
223-gordon reload
167+gordon daemon reload
224168 ```
225169
226-`gordon reload` sends `SIGUSR1` to the running Gordon process.
170+`gordon daemon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files.
227171
228172 ## Event System
229173
230-Gordon uses an internal event system for coordination:
231-
232-| Event | Trigger | Action |
233-|-------|---------|--------|
234-| `image.pushed` | Image pushed to registry | Deploy container |
235-| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
236-| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
237-| `container.deployed` | Container started | Update proxy cache |
174+Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests.
238175
239176 ## Backups and Recovery
240177
241-Gordon can run logical PostgreSQL backups for attachment containers.
178+Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[services.<name>.database]]`, `[services.<name>.backup]`); storage infrastructure (destinations, schedules, retention) stays global.
242179
243-Current design:
180+Stored backups are never deleted when declarations change — only schedules update on deploy.
244181
245-1. Detect PostgreSQL attachments attached to a route
246-2. Execute `pg_dump -Fc` through Gordon runtime operations
247-3. Store backup artifacts on local filesystem storage
248-4. Expose backup actions through admin API and CLI
249-
250-This is intentionally scoped for operational safety and predictable behavior.
251-Future extensions can add physical backups, PITR, and remote object storage adapters.
252182 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).
253183
254-## Container Labels
184+## Container Identity
255185
256-Gordon uses labels to track managed containers:
186+Gordon stamps app ownership labels on every container and volume it creates:
257187
258188 | Label | Purpose |
259189 |-------|---------|
260-| `gordon.managed=true` | Identifies Gordon-managed containers |
261-| `gordon.domain` | Domain this container serves |
262-| `gordon.image` | Image name and tag |
263-| `gordon.route` | Route this container handles |
264-| `gordon.attachment=true` | Container is an attachment service |
265-| `gordon.attached-to` | Which route this attachment serves |
266-
267-## Proxy Port Selection
268-
269-When a container exposes multiple ports, Gordon needs to know which one serves HTTP:
270-
271-```dockerfile
272-FROM gitea/gitea:latest
273-LABEL gordon.proxy.port=3000 # Route HTTP to port 3000
274-EXPOSE 22 # SSH
275-EXPOSE 3000 # HTTP
276-```
190+| `gordon.managed=true` | Identifies Gordon-managed resources |
191+| `gordon.app` | App public name |
192+| `gordon.app.service` | Service name |
193+| `gordon.app.revision` | Active revision that created it |
277194
278-Without the label, Gordon uses the first exposed port.
195+Queries by logical identity use labels, never name parsing. Unknown resources (no labels, old labels) are preserved, never adopted or deleted.
279196
280197 ## Related
281198
282199 - [Configuration Reference](./config/index.md)
283-- [Docker Labels Reference](./reference/docker-labels.md)
284-- [Environment Variables](./config/env.md)
200+- [Apps CLI](./cli/apps.md)
201+- [Getting Started](./getting-started.md)
A docs/config/apps.md +191 -0
@@ -0,0 +1,191 @@
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+[services.web]
13+image = "gordon.mydomain.com/blog:1.4.2"
14+
15+[[services.web.http]]
16+host = "blog.mydomain.com"
17+port = 3000
18+```
19+
20+Each `[services.<name>]` table declares one service, and the table key is the service name. A name may contain dots, so quote the key when it does: `[services."web.api"]`, `[[services."web.api".http]]`.
21+
22+## Full Example
23+
24+```toml
25+name = "blog"
26+
27+[env] # optional, app-wide public env for all services
28+APP_ENV = "production"
29+
30+[services.web] # one table per service; the key is the name
31+image = "gordon.mydomain.com/blog:1.4.2"
32+command = ["node", "server.js"] # optional override
33+stop_grace = "30s" # optional, default 30s
34+devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>]
35+
36+[services.web.readiness] # optional explicit readiness
37+type = "http"
38+path = "/healthz"
39+port = 3000
40+timeout = "30s"
41+
42+[[services.web.http]] # 0..n HTTP interfaces
43+host = "blog.mydomain.com"
44+port = 3000
45+tls = "auto" # auto | always | never
46+
47+[[services.web.http]] # optional private interface
48+visibility = "internal" # public (default) | internal
49+port = 8080 # required; no host or tls
50+
51+[services.web.secrets] # ENV name -> secret name (values in pass)
52+DATABASE_URL = "database-url"
53+
54+[[services.web.volume]] # 0..n named volumes only
55+name = "web-data"
56+path = "/data"
57+readonly = false
58+
59+[[services.web.bind]] # 0..n; name references [app_mounts.<name>]
60+name = "app-logs" # policy name, never a host path
61+path = "/var/log/app" # absolute container destination
62+readonly = true
63+
64+[[services.web.database]] # explicit database declarations
65+name = "main"
66+type = "postgres"
67+schedule = "daily"
68+
69+[services.web.backup] # backup targets by reference
70+postgres = ["main"]
71+volume = ["web-data"]
72+
73+[[network.shared]] # optional shared-network memberships
74+network = "backend"
75+services = ["web"]
76+```
77+
78+## Identity Rules
79+
80+- App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness.
81+- Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected.
82+- Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
83+- Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
84+- 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.
85+
86+## Services
87+
88+One or more explicitly named image-backed services per app, each in its own `[services.<name>]` table. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none.
89+
90+```toml
91+[[services.web.tcp]]
92+entrypoint = "game" # names installation entrypoints.<name>
93+port = 25565
94+publish = "0.0.0.0:25565"
95+
96+[[services.web.udp]]
97+entrypoint = "game"
98+port = 28015
99+publish = "0.0.0.0:28015"
100+```
101+
102+RCON is ordinary TCP: use TCP interfaces, no special RCON kind.
103+
104+`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.
105+
106+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.
107+
108+## Environment and Secrets
109+
110+- `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.
111+- `[services.<name>.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>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.
112+- There is no `[services.<name>.env]` key — service-specific values must use secrets.
113+
114+## Volumes and Databases
115+
116+- Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible.
117+- Manifests never carry host paths. A `[[services.<name>.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md).
118+- Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them.
119+- `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time.
120+- `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations.
121+- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once.
122+- A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
123+- `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).
124+- `[services.<name>.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.
125+
126+## HTTP Interfaces
127+
128+`[[services.<name>.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty.
129+
130+```toml
131+[[services.web.http]] # public: proxied by host
132+host = "blog.mydomain.com"
133+port = 3000
134+tls = "auto" # auto | always | never
135+
136+[[services.web.http]] # internal: not published (app private network only)
137+visibility = "internal"
138+port = 8080
139+```
140+
141+- `public`: `host` is required and must be a valid public hostname, and `tls` is `auto` (default when absent), `always`, or `never`. A public interface gets a proxy route, a global host reservation, a certificate target when TLS applies, and a `127.0.0.1` loopback backend publication.
142+- `internal`: creates no proxy route, no host reservation, no certificate target, and no host port publication, so it is never reachable through the host or proxy plane. `port` is required, `host` must be absent, and `tls` must be absent — any declared TLS value is rejected. It is still a declared TCP-capable container port for readiness metadata. Reachability follows network membership, not visibility: any container attached to a network this service joins can reach the port — sibling services on the app's own private network, and peers on any `[[network.shared]]` network the service is enrolled in.
143+
144+There is no `.internal` pseudo-domain: internal interfaces carry no hostname at all.
145+
146+A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected at apply time, because publication is socket-level. Duplicate internal HTTP ports within a service are rejected the same way.
147+
148+## Readiness
149+
150+`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. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[services.<name>.readiness]` type; the internal port is never temporarily published to the host to probe it.
151+
152+## TLS
153+
154+TLS applies to public interfaces only; an internal interface never declares `tls`.
155+
156+- `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled.
157+- `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.
158+- `never` stays plaintext and is never issued an app certificate.
159+
160+## Networks
161+
162+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.
163+
164+Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md).
165+
166+## Telemetry
167+
168+When [telemetry log export](./telemetry.md#logs) is enabled, the stdout and stderr of every service are exported by default under `service.name = "<app>.<service>"`. `logs = false` opts out; a service setting overrides the app setting:
169+
170+```toml
171+[telemetry]
172+logs = false # no service of this app exports...
173+
174+[services.web.telemetry]
175+logs = true # ...except web
176+```
177+
178+Changing either setting is a service change: it applies on the next deploy.
179+
180+## Strictness
181+
182+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.
183+
184+The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest).
185+
186+## Related
187+
188+- [Apps CLI](../cli/apps.md)
189+- [Secrets](./secrets.md)
190+- [Volumes](./volumes.md)
191+- [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 -5
@@ -87,16 +87,14 @@ Admin scopes (for remote CLI):
8787 | Scope | Permission |
8888 |-------|------------|
8989 | `admin:*:*` | Full admin access |
90-| `admin:routes:read` | Read-only routes access |
91-| `admin:routes:write` | Routes write access |
90+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
91+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
9292 | `admin:config:read` | Read-only config access |
9393 | `admin:config:write` | Config write access |
9494 | `admin:status:read` | Read-only status/health |
9595 | `admin:logs:read` | Read-only logs access |
9696 | `admin:volumes:read` | List Docker volumes |
9797 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |
98-| `admin:secrets:read` | List secret keys |
99-| `admin:secrets:write` | Set/delete secrets |
10098
10199 Examples:
102100
@@ -143,10 +141,19 @@ gordon auth token generate --subject temp --expiry 30d
143141
144142 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
145143
146-- `/admin/*` endpoints are not registered.
144+- `/admin/*` endpoints are not registered on the TCP listener.
147145 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
148146 - Remote registry and remote admin access are disabled.
149147
148+Local `gordon apps` commands keep working through an owner-only administration socket:
149+
150+- 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.
151+- 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.
152+- 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.
153+- Unix platforms only. Never share, mount, or forward the socket: filesystem access to it is the credential.
154+
155+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.
156+
150157 Use this only when Gordon is intended for local machine usage.
151158
152159 ## 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 +17 -8
@@ -3,7 +3,11 @@
33 Gordon has two backup flows:
44
55 - **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. Administrative bind mounts are operator-owned host paths and are never archived.
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.
711
812 ## Database backups
913
@@ -64,19 +68,24 @@ keep = 14
6468 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
6569 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
6670 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
67-| `backups.volumes.retention.keep` | `14` | Completed archives to keep per domain + volume |
71+| `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |
6872
69-Volume backup objects are stored under:
73+Volume backup objects are stored under the canonical app name:
7074
7175 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
76+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
77+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
7478 ```
7579
80+Objects written by earlier versions under a domain prefix are not read or
81+migrated: back them up and re-run the app's volume backups if you need them in
82+the current layout.
83+
7684 ## Notes
7785
78-- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
79-- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.
86+- Volume backups archive the app's declared volumes that its backup declaration references.
87+- Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.
88+- Database backups archive the app's declared PostgreSQL databases that its backup declaration references.
8089 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
8190 - Automated volume restore is not part of the MVP.
8291 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
@@ -84,6 +93,6 @@ Volume backup objects are stored under:
8493
8594 ## Related
8695
87-- [Attachments Configuration](./attachments.md)
96+- [App Manifest](./apps.md)
8897 - [CLI Backup Command](../cli/backup.md)
8998 - [Configuration Reference](./reference.md)
D docs/config/deploy.md +0 -121
@@ -1,121 +0,0 @@
1-# Deploy Configuration
2-
3-Controls how Gordon pulls images during deployment.
4-
5-## Example
6-
7-```toml
8-[deploy]
9-pull_policy = "if-tag-changed"
10-readiness_mode = "auto"
11-health_timeout = "90s"
12-readiness_delay = "5s"
13-drain_mode = "auto"
14-drain_timeout = "30s"
15-drain_delay = "2s"
16-```
17-
18-## Settings
19-
20-### `deploy.pull_policy`
21-
22-How to decide when to pull an image:
23-
24-- `always`: always pull, even if the tag exists locally.
25-- `if-not-present`: only pull when the tag is missing locally.
26-- `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`).
27-
28-Default: `if-tag-changed`
29-
30-### `deploy.readiness_delay`
31-
32-How long Gordon waits after a container first reports `running` before it is
33-considered ready for traffic.
34-
35-- Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`).
36-- If the container briefly exits right after this delay, Gordon now waits up to
37- 30 additional seconds for it to recover before failing the deploy.
38-
39-Default: `"5s"`
40-
41-### `deploy.readiness_mode`
42-
43-How Gordon determines that a newly started container is ready for traffic.
44-
45-- `auto`: use Docker health status if the container has a healthcheck; otherwise
46- fall back to `deploy.readiness_delay`.
47-- `docker-health`: require a Docker healthcheck and wait for `healthy`.
48- Deploy fails with `no healthcheck detected` if absent.
49-- `delay`: always use `deploy.readiness_delay`.
50-
51-Default: `"auto"`
52-
53-### `deploy.health_timeout`
54-
55-Maximum time Gordon waits for health-based readiness in `auto`/`docker-health`
56-mode before failing the deploy.
57-
58-- Uses Go duration format (examples: `"30s"`, `"90s"`, `"2m"`).
59-- Default `"90s"` balances typical app startup time with fast failure feedback.
60-- Increase for slow cold-starts, migrations, or heavy warmup workloads.
61-
62-Default: `"90s"`
63-
64-### `deploy.drain_delay`
65-
66-How long Gordon waits after synchronous proxy cache invalidation before stopping
67-the previous container during zero-downtime replacement.
68-
69-- Uses Go duration format (examples: `"2s"`, `"10s"`, `"1m"`).
70-- Applied only when a previous container exists and cache invalidation was
71- triggered for the deployed domain.
72-- If explicitly set to `"0s"` (or a negative duration), delay is disabled and
73- Gordon switches immediately after invalidation.
74-
75-Default: `"2s"`
76-
77-### `deploy.drain_mode`
78-
79-How Gordon decides when it is safe to stop the previous container after routing
80-traffic to the new one.
81-
82-- `auto`: wait for in-flight proxy requests to drain when available; otherwise
83- fall back to `deploy.drain_delay`.
84-- `inflight`: wait for in-flight proxy requests on the previous container to
85- reach zero (bounded by `deploy.drain_timeout`).
86-- `delay`: always use `deploy.drain_delay`.
87-
88-Default: `"auto"`
89-
90-### `deploy.drain_timeout`
91-
92-Maximum time Gordon waits for in-flight request drain before continuing with old
93-container shutdown.
94-
95-- Uses Go duration format (examples: `"10s"`, `"30s"`, `"1m"`).
96-- Default `"30s"` is a practical upper bound for most HTTP workloads.
97-- Increase for long-lived requests (large uploads, SSE, long polling).
98-
99-Default: `"30s"`
100-
101-## Container security profile
102-
103-Runtime hardening is configured under `[containers]`:
104-
105-```toml
106-[containers]
107-security_profile = "compat" # "compat" or "strict"
108-```
109-
110-- `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults.
111-- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile.
112-
113-## Notes
114-
115-- Changes to `deploy.pull_policy` require a restart to take effect.
116-- Changes to `deploy.readiness_mode` require a restart to take effect.
117-- Changes to `deploy.health_timeout` require a restart to take effect.
118-- Changes to `deploy.readiness_delay` require a restart to take effect.
119-- Changes to `deploy.drain_mode` require a restart to take effect.
120-- Changes to `deploy.drain_timeout` require a restart to take effect.
121-- Changes to `deploy.drain_delay` require a restart to take effect.
D docs/config/env.md +0 -207
@@ -1,207 +0,0 @@
1-# Environment Variables
2-
3-Configure per-application environment variables using domain-based files.
4-
5-## Configuration
6-
7-```toml
8-[env]
9-dir = "~/.gordon/env" # Default location
10-```
11-
12-## Options
13-
14-| Option | Type | Default | Description |
15-|--------|------|---------|-------------|
16-| `dir` | string | `~/.gordon/env` | Directory containing .env files |
17-
18-## How It Works
19-
20-Gordon loads environment variables from files named after the domain:
21-
22-```
23-~/.gordon/env/
24-├── app_mydomain_com.env # For app.mydomain.com
25-├── api_mydomain_com.env # For api.mydomain.com
26-└── admin_mydomain_com.env # For admin.mydomain.com
27-```
28-
29-**Naming convention:** Replace dots with underscores, add `.env` extension.
30-
31-| Domain | File Name |
32-|--------|-----------|
33-| `app.mydomain.com` | `app_mydomain_com.env` |
34-| `api.company.io` | `api_company_io.env` |
35-| `staging.app.dev` | `staging_app_dev.env` |
36-
37-## Backend Behavior
38-
39-The `auth.secrets_backend` setting changes where route secrets live:
40-
41-### Pass Backend
42-
43-- Env files are not used for route secrets.
44-- Use `gordon secrets set` to store per-domain secrets in pass.
45-- Existing `.env` files are migrated on startup and renamed to `.env.migrated`.
46-
47-### SOPS or Unsafe Backend
48-
49-- Env files remain the source of truth.
50-- Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
51-
52-## File Format
53-
54-Standard `.env` file format:
55-
56-```bash
57-# app_mydomain_com.env
58-NODE_ENV=production
59-PORT=3000
60-DATABASE_URL=postgresql://db:5432/myapp
61-API_KEY=sk-1234567890
62-DEBUG=false
63-```
64-
65-## Variable Merging
66-
67-Variables are merged from multiple sources (lowest to highest priority):
68-
69-1. **Dockerfile ENV** - Base defaults from image
70-2. **.env file** - Overrides Dockerfile values
71-
72-```dockerfile
73-# Dockerfile
74-ENV NODE_ENV=development
75-ENV PORT=3000
76-```
77-
78-```bash
79-# app_mydomain_com.env
80-NODE_ENV=production # Overrides Dockerfile
81-# PORT not set, uses 3000 from Dockerfile
82-```
83-
84-Result: `NODE_ENV=production`, `PORT=3000`
85-
86-## Secret Provider Syntax
87-
88-Reference secrets from configured backends in env files:
89-
90-### Pass (Unix Password Manager)
91-
92-```bash
93-DATABASE_PASSWORD=${pass:myapp/database/password}
94-API_SECRET=${pass:company/api-secret}
95-JWT_KEY=${pass:production/jwt-signing-key}
96-```
97-
98-### SOPS (Encrypted Files)
99-
100-```bash
101-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
102-API_SECRET=${sops:production.yaml:api.secret}
103-STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
104-```
105-
106-### Syntax Reference
107-
108-| Provider | Syntax | Example |
109-|----------|--------|---------|
110-| pass | `${pass:<path>}` | `${pass:myapp/db-password}` |
111-| sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` |
112-
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 Permissions
177-
178-Gordon creates env files with secure permissions:
179-- Directory: `0700` (owner only)
180-- Files: `0600` (owner only)
181-
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-## Related
204-
205-- [Secrets Configuration](./secrets.md)
206-- [Routes](./routes.md)
207-- [Attachments](./attachments.md)
M docs/config/external-routes.md +1 -1
@@ -79,5 +79,5 @@ vim ~/.config/gordon/gordon.toml
7979
8080 ## Related
8181
82-- [Routes Configuration](./routes.md)
82+- [App Manifest](./apps.md)
8383 - [Configuration Overview](./index.md)
M docs/config/images.md +21 -6
@@ -6,18 +6,20 @@ Configure automatic image cleanup for Docker runtime images and local registry s
66
77 When enabled, Gordon runs a scheduled image prune job that:
88
9-- Prunes dangling runtime images.
9+- Prunes dangling runtime images that carry positive Gordon provenance.
1010 - Applies tag retention to registry repositories.
1111 - Preserves the `latest` tag.
1212 - Removes unreferenced blobs after tag cleanup.
13+- Reports every protected and unknown candidate with its reason.
1314
14-The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention).
15+The CLI `gordon images prune` runs the same use case and planner as the scheduled job, with the same defaults (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). App state existing on the server is normal and never disables pruning; the plan decides per candidate.
1516
1617 ## Configuration
1718
1819 ```toml
1920 [images]
20-allowed_registries = []
21+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are already allowed.
22+allowed_registries = ["registry.internal:5000"]
2123 require_digest = false
2224
2325 [images.prune]
@@ -30,19 +32,32 @@ keep_last = 3
3032
3133 | Key | Type | Default | Description |
3234 |-----|------|---------|-------------|
33-| `images.allowed_registries` | array | `[]` | Allowlist for external image registries. Empty means no external registries are accepted. Unqualified image names such as `nginx:latest` are treated as Docker Hub (`docker.io`) for policy checks. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |
34-| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |
35+| `images.allowed_registries` | array | `[]` | Additional registry hostname+port entries. Docker Hub (`docker.io`, canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Include non-default ports, e.g. `"registry.example.com:5000"`. Hostnames are case-insensitive; one trailing dot and port `443` are canonicalized. This setting does not configure registry credentials. |
36+| `images.require_digest` | bool | `false` | Require every image reference, including Gordon registry images, to use a valid `@sha256:<64 hex chars>` digest. |
3537 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
3638 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
3739 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
3840
41+The policy validates registry names and strict SHA-256 digest syntax at manifest apply, digest resolution, deployment preflight, and immediately before each pull. It rejects malformed, userinfo-bearing, and ambiguous authorities. External app images currently require digest-pinned references, independently of `require_digest`; external tag resolution is not available. External pulls are anonymous, so adding a host to `allowed_registries` does not enable authenticated private-registry access. This hostname allowlist does **not** prove that DNS resolves to a public address and does not constrain runtime egress; enforce destination-level restrictions in the host firewall or runtime network policy.
42+
3943 ## Retention Behavior
4044
4145 - `latest` is always preserved.
4246 - `keep_last` applies per repository and counts non-`latest` tags.
43-- `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
47+- `keep_last` sets a minimum retained set, not a maximum: a tag named by durable app state, or referenced by the OCI closure of a protected manifest, survives beyond the window.
48+- `keep_last = 0` skips registry tag/blob cleanup entirely (runtime dangling prune still runs).
4449 - Negative `keep_last` values are invalid.
4550
51+## Prune Safety
52+
53+A prune deletes only resources it can positively prove safe. Each candidate gets one verdict: `eligible` (deleted), `protected` (a durable fact claims it), or `unknown` (a required fact could not be read). Protected and unknown candidates are reported and left in place; an operation that deletes nothing is a success.
54+
55+Durable protection covers the DESIRED and ACTIVE state of every app, including stopped and partially converged services; recovery inhibitions; staged and committed apply intents; unfinished operation journal entries; runtime container use for running and stopped containers; recent uploads; and the transitive OCI closure of every retained manifest. Durable digest roots are repository-qualified, so moving a tag away from a deployed image keeps that manifest's config, layers, child manifests, and subject content protected. An unreadable, unsupported, or unresolvable manifest makes the blobs whose safety depended on it `unknown` instead of eligible.
56+
57+Registry content is repository-scoped: a blob is served only to a repository that completed an upload of that digest, and a manifest may only reference config, layers, and child manifests that repository owns. Image labels never authorize deletion of a runtime image; eligibility requires a matching released ownership claim from the app that pinned it, no container use, and a complete inventory.
58+
59+An exclusive GC lease serializes prune against app apply, deploy, start, restart, remove, recovery, and restore. Those paths hold a shared lease from validation/resource acquisition through the durable publication of that resource's protection, and an apply verifies the expected desired revision so a stale apply cannot overwrite a newer one.
60+
4661 ## Related
4762
4863 - [CLI Images Command](../cli/images.md)
M docs/config/index.md +21 -72
@@ -19,12 +19,11 @@ gordon_domain = "gordon.mydomain.com"
1919 [entrypoints.edge]
2020 address = ":443"
2121 protocol = "smart_tcp"
22-
23-[routes]
24-"app.mydomain.com" = "myapp:latest"
2522 ```
2623
27-> **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
24+Application workloads live in standalone app files, not in `gordon.toml` — see [App Manifest](./apps.md). Retired workload keys such as `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` are rejected at startup. Installation-level `[[services]]` for standalone L4 workloads remains valid.
25+
26+> **Note:** `gordon_domain` is the canonical registry and Admin API host.
2827 >
2928 > For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename).
3029
@@ -61,16 +60,6 @@ per_ip_rps = 50 # Max requests/second per IP
6160 burst = 100 # Burst size
6261 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
6362
64-# Deploy behavior
65-[deploy]
66-pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
67-readiness_mode = "auto" # auto, docker-health, delay
68-health_timeout = "90s" # Max wait for health-based readiness
69-readiness_delay = "5s" # Wait after running before ready
70-drain_mode = "auto" # auto, inflight, delay
71-drain_timeout = "30s" # Max wait for in-flight drain
72-drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
73-
7463 # Container runtime profile
7564 [containers]
7665 security_profile = "compat" # compat or strict
@@ -87,12 +76,8 @@ max_size = 100 # MB before rotation
8776 max_backups = 3 # Old files to keep
8877 max_age = 28 # Days to keep
8978
90-[logging.container_logs]
91-enabled = true
92-dir = "~/.gordon/logs/containers" # Default location
93-max_size = 100
94-max_backups = 3
95-max_age = 28
79+# Workload logs are streamed directly from the container runtime with
80+# `gordon apps logs APP --service SERVICE`.
9681
9782 # Telemetry (OpenTelemetry)
9883 [telemetry]
@@ -101,42 +86,24 @@ endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint
10186 auth_token = "" # Base64 user:password for Basic auth
10287 traces = true # Export traces
10388 metrics = true # Export metrics
104-logs = true # Bridge zerolog to OTLP logs
89+logs = true # Export Gordon, access, and app logs
10590 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
10691
107-# Environment variables
108-[env]
109-dir = "~/.gordon/env" # Default location
110-
11192 # Volume settings
11293 [volumes]
11394 auto_create = true # Auto-create from Dockerfile VOLUME
11495 prefix = "gordon" # Volume name prefix
11596 preserve = true # Keep volumes on container removal
11697
117-# Network isolation
98+# Installation network policy (prefix filter for `gordon daemon networks`)
11899 [network_isolation]
119-enabled = true # Per-app isolated networks
100+enabled = true # Gordon-managed network policy
120101 network_prefix = "gordon" # Network name prefix
121102 internal = false # Set true to block direct egress from isolated networks
122103
123-# Auto-route
124-[auto_route]
125-enabled = false # Auto-create routes from image names
126-
127-# Routes (required)
128-[routes]
129-"app.mydomain.com" = "myapp:latest"
130-"api.mydomain.com" = "myapi:v2.1.0"
131-
132-# Network groups
133-[network_groups]
134-"backend" = ["app.mydomain.com", "api.mydomain.com"]
135-
136-# Attachments
137-[attachments]
138-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
139-"backend" = ["rabbitmq:latest"]
104+# REMOVED in v3 (declare apps in standalone files, see ./apps.md):
105+# [routes], [attachments], [network_groups],
106+# [service_routes], [auto_route], [previews]
140107
141108 # Backups
142109 [backups]
@@ -152,8 +119,8 @@ monthly = 12
152119
153120 # Images
154121 [images]
155-allowed_registries = [] # Explicit external registries allowed for deploy/attachments
156-require_digest = false # Require digests for allowlisted external registries
122+allowed_registries = [] # Additional exact registry hostname+port entries
123+require_digest = false # Require SHA-256 digests for every image registry
157124
158125 [images.prune]
159126 enabled = false
@@ -161,6 +128,8 @@ schedule = "daily"
161128 keep_last = 3
162129 ```
163130
131+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).
132+
164133 ## Configuration Sections
165134
166135 | Section | Description | Documentation |
@@ -168,19 +137,14 @@ keep_last = 3
168137 | `[server]` | Core server settings | [Server](./server.md) |
169138 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
170139 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
171-| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
172140 | `[logging]` | Logging configuration | [Logging](./logging.md) |
173141 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
174-| `[env]` | Environment variable settings | [Environment](./env.md) |
175142 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
176-| `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
177-| `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
178-| `[routes]` | Domain to image mapping | [Routes](./routes.md) |
143+| `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |
179144 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180145 | `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
181-| `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
182-| `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
183-| `[backups]` | Database backups | [Backups](./backups.md) |
146+| App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) |
147+| `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) |
184148 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
185149 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
186150
@@ -200,13 +164,6 @@ keep_last = 3
200164 | `api.rate_limit.per_ip_rps` | `50` |
201165 | `api.rate_limit.burst` | `100` |
202166 | `api.rate_limit.trusted_proxies` | `[]` |
203-| `deploy.pull_policy` | `"if-tag-changed"` |
204-| `deploy.readiness_mode` | `"auto"` |
205-| `deploy.health_timeout` | `"90s"` |
206-| `deploy.readiness_delay` | `"5s"` |
207-| `deploy.drain_mode` | `"auto"` |
208-| `deploy.drain_timeout` | `"30s"` |
209-| `deploy.drain_delay` | `"2s"` |
210167 | `containers.security_profile` | `"compat"` |
211168 | `logging.level` | `"info"` |
212169 | `logging.format` | `"console"` |
@@ -214,13 +171,11 @@ keep_last = 3
214171 | `logging.file.max_size` | `100` |
215172 | `logging.file.max_backups` | `3` |
216173 | `logging.file.max_age` | `28` |
217-| `logging.container_logs.enabled` | `true` |
218174 | `volumes.auto_create` | `true` |
219175 | `volumes.prefix` | `"gordon"` |
220176 | `volumes.preserve` | `true` |
221177 | `network_isolation.enabled` | `true` |
222178 | `network_isolation.internal` | `false` |
223-| `auto_route.enabled` | `false` |
224179 | `backups.enabled` | `false` |
225180 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
226181 | `images.allowed_registries` | `[]` |
@@ -236,14 +191,14 @@ keep_last = 3
236191 | `telemetry.logs` | `true` |
237192 | `telemetry.trace_sample_rate` | `1.0` |
238193
239-When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
194+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).
240195
241196 ## Hot Reload
242197
243198 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
244199
245200 ```bash
246-gordon reload
201+gordon daemon reload
247202 ```
248203
249204 ### Hot-reloaded (no restart needed)
@@ -256,7 +211,6 @@ gordon reload
256211 | `server.max_proxy_response_size` |
257212 | `server.max_concurrent_conns` |
258213
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.
260214
261215 ### Requires restart
262216
@@ -267,11 +221,6 @@ gordon reload
267221 | `server.max_blob_chunk_size` |
268222 | `server.max_blob_size` |
269223 | `auth.*` |
270-| `deploy.readiness_mode` |
271-| `deploy.readiness_delay` |
272-| `deploy.health_timeout` |
273-| `deploy.drain_mode` |
274-| `deploy.drain_timeout` |
275224
276225 ## Environment Variable Override
277226
@@ -286,7 +235,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
286235 ## Related
287236
288237 - [Server Configuration](./server.md)
289-- [Routes Configuration](./routes.md)
238+- [App Manifest](./apps.md)
290239 - [External Routes](./external-routes.md)
291240 - [Standalone Services](./services.md)
292241 - [Traffic Plane](./traffic.md)
M docs/config/logging.md +21 -92
@@ -1,6 +1,6 @@
11 # Logging Configuration
22
3-Configure application and container log collection.
3+Configure Gordon process and HTTP access logging. Workload logs are read directly from the container runtime.
44
55 ## Configuration
66
@@ -16,13 +16,6 @@ max_size = 100
1616 max_backups = 3
1717 max_age = 28
1818
19-[logging.container_logs]
20-enabled = true
21-dir = "~/.gordon/logs/containers"
22-max_size = 100
23-max_backups = 3
24-max_age = 28
25-
2619 [logging.access_log]
2720 enabled = false
2821 format = "json"
@@ -44,24 +37,19 @@ syslog_identifier = "gordon-access"
4437
4538 | Option | Type | Default | Description |
4639 |--------|------|---------|-------------|
47-| `file.enabled` | bool | `false` | Enable file-based logging |
48-| `file.path` | string | - | Path to main log file |
40+| `file.enabled` | bool | `false` | Enable process file logging |
41+| `file.path` | string | `{data_dir}/logs/gordon.log` | Process log path |
4942 | `file.max_size` | int | `100` | Max file size in MB before rotation |
5043 | `file.max_backups` | int | `3` | Number of old files to keep |
5144 | `file.max_age` | int | `28` | Days to keep old files |
5245
53-The Admin API and `gordon logs` read from the process log file. Keep
54-`logging.file.enabled` set to `true` if you need process log streaming.
46+The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming.
5547
56-### Container Logs
48+### Workload Logs
5749
58-| Option | Type | Default | Description |
59-|--------|------|---------|-------------|
60-| `container_logs.enabled` | bool | `true` | Collect container stdout/stderr |
61-| `container_logs.dir` | string | `{data_dir}/logs/containers` | Directory for container logs |
62-| `container_logs.max_size` | int | `100` | Max file size in MB |
63-| `container_logs.max_backups` | int | `3` | Old files to keep |
64-| `container_logs.max_age` | int | `28` | Days to keep files |
50+`gordon apps logs APP --service SERVICE` reads stdout and stderr directly from the container runtime. Gordon does not persist workload logs to files; configure retention in the container runtime's logging driver.
51+
52+The accepted `logging.container_logs` configuration fields are currently not connected to a production log sink and do not create files.
6553
6654 ### Access Log
6755
@@ -89,31 +77,9 @@ Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingesti
8977 | `warn` | Warnings |
9078 | `error` | Errors only |
9179
92-## Log Directory Structure
93-
94-```
95-~/.gordon/logs/
96-├── gordon.log # Main application logs
97-├── gordon.log.1 # Rotated log
98-├── gordon.log.2.gz # Compressed old log
99-└── containers/
100- ├── app_mydomain_com.log # Container logs by domain
101- └── api_mydomain_com.log
102-```
103-
10480 ## Log Rotation
10581
106-Gordon uses automatic log rotation:
107-
108-1. **Size-based**: Rotates when file exceeds `max_size` MB
109-2. **Age-based**: Removes files older than `max_age` days
110-3. **Count-based**: Keeps only `max_backups` old files
111-4. **Compression**: Old files are gzip compressed
112-
113-Example with defaults:
114-- Log grows to 100MB → rotated to `gordon.log.1`
115-- After 3 rotations → oldest is compressed
116-- Files older than 28 days → deleted
82+Process file logs and file-based access logs rotate by size and retain files by count and age. Old files are compressed.
11783
11884 ## Examples
11985
@@ -131,13 +97,6 @@ max_size = 10
13197 max_backups = 2
13298 max_age = 7
13399
134-[logging.container_logs]
135-enabled = true
136-dir = "./logs/containers"
137-max_size = 10
138-max_backups = 2
139-max_age = 7
140-
141100 [logging.access_log]
142101 enabled = true
143102 format = "json"
@@ -160,12 +119,11 @@ max_size = 100
160119 max_backups = 10
161120 max_age = 90
162121
163-[logging.container_logs]
122+[logging.access_log]
164123 enabled = true
165-dir = "~/.gordon/logs/containers"
166-max_size = 100
167-max_backups = 10
168-max_age = 90
124+format = "json"
125+output = "journald"
126+syslog_identifier = "gordon-access"
169127 ```
170128
171129 ### Minimal (Console Only)
@@ -177,60 +135,31 @@ format = "console"
177135
178136 [logging.file]
179137 enabled = false
180-
181-[logging.container_logs]
182-enabled = true
183138 ```
184139
185140 ## Viewing Logs
186141
187-### Gordon Logs
142+### Gordon Process Logs
188143
189144 ```bash
190-# Using gordon logs command
191-gordon logs -f # Follow logs
192-gordon logs -n 100 # Last 100 lines
193-
194-# Direct file access
145+gordon daemon logs -f
146+gordon daemon logs -n 100
195147 tail -f ~/.gordon/logs/gordon.log
196-
197-# With journalctl (if using systemd)
198148 journalctl --user -u gordon -f
199149 ```
200150
201-### Container Logs
151+### Workload Logs
202152
203153 ```bash
204-# View container logs
205-tail -f ~/.gordon/logs/containers/app_mydomain_com.log
206-
207-# All container logs
208-ls ~/.gordon/logs/containers/
154+gordon apps logs blog --service web
155+gordon apps logs blog --service web --follow
209156 ```
210157
211-## Log Format
212-
213-### Console Format
214-
215-```
216-2024-01-15T10:30:00Z INF container deployed domain=app.mydomain.com image=myapp:latest
217-2024-01-15T10:30:01Z INF proxy routing updated route=app.mydomain.com
218-```
219-
220-### JSON Format
221-
222-```json
223-{"level":"info","time":"2024-01-15T10:30:00Z","message":"container deployed","domain":"app.mydomain.com","image":"myapp:latest"}
224-{"level":"info","time":"2024-01-15T10:30:01Z","message":"proxy routing updated","route":"app.mydomain.com"}
225-```
158+These commands stream from the container runtime; they do not read Gordon-managed workload log files.
226159
227160 ## Security
228161
229-Log files are created with secure permissions:
230-- Directories: `0700` (owner only)
231-- Files: `0600` (owner only)
232-
233-Sensitive values (passwords, tokens) are automatically redacted.
162+Process and access log files are created with owner-only permissions. Gordon redacts common credential patterns when serving logs, but application output may still contain sensitive data. Restrict process-log, journal, runtime, and `admin:logs:read` access.
234163
235164 ## Related
236165
D docs/config/network-groups.md +0 -188
@@ -1,188 +0,0 @@
1-# Network Groups
2-
3-Group multiple applications into shared networks for inter-service communication.
4-
5-## Configuration
6-
7-```toml
8-[network_groups]
9-"backend" = ["app.mydomain.com", "api.mydomain.com"]
10-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com"]
11-```
12-
13-## Syntax
14-
15-```toml
16-[network_groups]
17-"<group-name>" = ["<domain>", "<domain>", ...]
18-```
19-
20-| Component | Description |
21-|-----------|-------------|
22-| `group-name` | Name for the shared network |
23-| `domains` | List of routes that share this network |
24-
25-## How It Works
26-
27-Network groups create shared Docker networks for multiple applications:
28-
29-```toml
30-[network_groups]
31-"backend" = ["app.mydomain.com", "api.mydomain.com"]
32-```
33-
34-Creates a shared network: `gordon-backend`
35-
36-```
37-┌────────────────────────────────────────────────┐
38-│ gordon-backend │
39-│ │
40-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
41-│ │ App │←──→│ API │←──→│ Postgres │ │
42-│ │ :3000 │ │ :8080 │ │ :5432 │ │
43-│ └──────────┘ └──────────┘ └──────────┘ │
44-│ │
45-└────────────────────────────────────────────────┘
46-```
47-
48-## Service Discovery
49-
50-Applications in the same group can communicate using container names:
51-
52-```toml
53-[routes]
54-"app.mydomain.com" = "frontend:latest"
55-"api.mydomain.com" = "backend:latest"
56-
57-[network_groups]
58-"services" = ["app.mydomain.com", "api.mydomain.com"]
59-```
60-
61-Server-side code (SSR, API routes) can reach other containers by name:
62-```javascript
63-// In server-side code (SSR / API route) - NOT browser JavaScript
64-const response = await fetch("http://backend:8080/api/data");
65-```
66-
67-Browser clients should use the public route instead:
68-```javascript
69-// In browser JavaScript
70-const response = await fetch("https://api.mydomain.com/api/data");
71-```
72-
73-## Shared Attachments
74-
75-Combine network groups with attachments for shared services:
76-
77-```toml
78-[network_groups]
79-"backend" = ["app.mydomain.com", "api.mydomain.com"]
80-
81-[attachments]
82-"backend" = ["postgres:latest", "redis:latest"]
83-```
84-
85-Both `app.mydomain.com` and `api.mydomain.com` can access:
86-- `postgres:5432`
87-- `redis:6379`
88-
89-## Multiple Groups
90-
91-An application can only be in one network group:
92-
93-```toml
94-# Each app belongs to one group
95-[network_groups]
96-"customer-facing" = ["app.mydomain.com", "api.mydomain.com"]
97-"internal" = ["admin.mydomain.com", "metrics.mydomain.com"]
98-```
99-
100-## Mixed Isolation
101-
102-Combine network groups with per-app attachments:
103-
104-```toml
105-[network_groups]
106-"backend" = ["app.mydomain.com", "api.mydomain.com"]
107-
108-[attachments]
109-# Shared by both apps
110-"backend" = ["redis:latest", "rabbitmq:latest"]
111-
112-# App-specific databases
113-"app.mydomain.com" = ["app-postgres:latest"]
114-"api.mydomain.com" = ["api-postgres:latest"]
115-```
116-
117-Result:
118-- Shared: Redis and RabbitMQ
119-- Isolated: Each app has its own Postgres
120-
121-## Examples
122-
123-### Microservices Architecture
124-
125-```toml
126-[routes]
127-"orders.mydomain.com" = "orders-service:latest"
128-"inventory.mydomain.com" = "inventory-service:latest"
129-"shipping.mydomain.com" = "shipping-service:latest"
130-"payments.mydomain.com" = "payments-service:latest"
131-
132-[network_groups]
133-"order-processing" = ["orders.mydomain.com", "inventory.mydomain.com", "shipping.mydomain.com"]
134-"payment-processing" = ["payments.mydomain.com"]
135-
136-[attachments]
137-"order-processing" = ["rabbitmq:latest"]
138-"payments.mydomain.com" = ["payments-db:latest"]
139-```
140-
141-### Monitoring Stack
142-
143-```toml
144-[routes]
145-"grafana.mydomain.com" = "grafana/grafana:latest"
146-"prometheus.mydomain.com" = "prom/prometheus:latest"
147-"alertmanager.mydomain.com" = "prom/alertmanager:latest"
148-
149-[network_groups]
150-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com", "alertmanager.mydomain.com"]
151-
152-[attachments]
153-"monitoring" = ["prometheus-data:latest"]
154-```
155-
156-### Multi-Tenant SaaS
157-
158-```toml
159-[routes]
160-"app.saas.com" = "saas-app:latest"
161-"api.saas.com" = "saas-api:latest"
162-"admin.saas.com" = "saas-admin:latest"
163-
164-# API and App share backend services
165-[network_groups]
166-"platform" = ["app.saas.com", "api.saas.com"]
167-
168-# Admin is isolated
169-[attachments]
170-"platform" = ["postgres:latest", "redis:latest", "elasticsearch:latest"]
171-"admin.saas.com" = ["admin-db:latest"]
172-```
173-
174-## Network Naming
175-
176-Group networks are named: `{network_prefix}-{group-name}`
177-
178-| Group | Network Name |
179-|-------|--------------|
180-| `backend` | `gordon-backend` |
181-| `monitoring` | `gordon-monitoring` |
182-| `order-processing` | `gordon-order-processing` |
183-
184-## Related
185-
186-- [Network Isolation](./network-isolation.md)
187-- [Attachments](./attachments.md)
188-- [Routes](./routes.md)
M docs/config/network-isolation.md +13 -150
@@ -1,6 +1,6 @@
11 # Network Isolation
22
3-Isolate applications in separate Docker networks for enhanced security.
3+Installation network policy for Gordon-managed networks.
44
55 ## Configuration
66
@@ -11,177 +11,40 @@ network_prefix = "gordon"
1111 internal = false
1212 ```
1313
14-## Migration Note
15-
16-As of this release, `network_isolation.enabled` defaults to `true` (previously `false`).
17-Existing installs that rely on a shared network must explicitly opt out:
18-
19-```toml
20-[network_isolation]
21-enabled = false
22-```
23-
2414 ## Options
2515
2616 | Option | Type | Default | Description |
2717 |--------|------|---------|-------------|
28-| `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
29-| `network_prefix` | string | `"gordon"` | Prefix for created networks |
30-| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. |
31-
32-## How It Works
33-
34-When network isolation is enabled, each application gets its own Docker network:
35-
36-```
37-[network_isolation]
38-enabled = true
39-network_prefix = "gordon"
40-
41-[routes]
42-"app.mydomain.com" = "myapp:latest"
43-"api.mydomain.com" = "myapi:latest"
44-```
45-
46-Creates two isolated networks:
47-- `gordon-app-mydomain-com`
48-- `gordon-api-mydomain-com`
49-
50-## Network Naming
51-
52-Networks are named: `{prefix}-{domain-with-dashes}`
53-
54-| Domain | Network Name |
55-|--------|--------------|
56-| `app.mydomain.com` | `gordon-app-mydomain-com` |
57-| `api.company.io` | `gordon-api-company-io` |
58-| `staging.app.dev` | `gordon-staging-app-dev` |
59-
60-## Security Benefits
61-
62-### Without Network Isolation
63-
64-All containers can potentially communicate:
65-
66-```
67-┌───────────────────────────────────────┐
68-│ Default Bridge Network │
69-│ │
70-│ App A ←──────→ App B ←──────→ App C │
71-│ ↕ ↕ ↕ │
72-│ DB A ←──────→ DB B ←──────→ DB C │
73-│ │
74-└───────────────────────────────────────┘
75-```
76-
77-### With Network Isolation
78-
79-Each app is isolated with its dependencies:
80-
81-```
82-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
83-│ gordon-app-a │ │ gordon-app-b │ │ gordon-app-c │
84-│ │ │ │ │ │
85-│ App A ←→ DB A │ │ App B ←→ DB B │ │ App C ←→ DB C │
86-│ │ │ │ │ │
87-└─────────────────┘ └─────────────────┘ └─────────────────┘
88- ↑ ↑ ↑
89- └───────── No direct communication ─────┘
90-```
91-
92-## Service Discovery
93-
94-Within an isolated network, services are discoverable by name:
18+| `enabled` | bool | `true` | Enable Gordon-managed network policy |
19+| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon networks` |
20+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
9521
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:
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.
15223
153-```toml
154-[network_isolation]
155-enabled = true
24+## Service-to-Service Networking
15625
157-[network_groups]
158-"backend" = ["app.mydomain.com", "api.mydomain.com"]
26+Every app container joins an incarnation-owned private network. Services of the same app communicate over that network and can resolve each other by service alias.
15927
160-[attachments]
161-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
162-```
28+Different apps are isolated by default: a container on one app network cannot reach another app's network. Cross-app communication happens only when both services declare the same `[[network.shared]]` membership, which attaches the explicitly enrolled containers to a shared network.
16329
164-Both apps share the `gordon-backend` network.
30+Readiness helpers never join shared networks and exist only on the target app's private network.
16531
16632 ## Inspecting Networks
16733
168-View created networks:
34+View Gordon-managed networks:
16935
17036 ```bash
37+gordon daemon networks
17138 docker network ls | grep gordon
172-# gordon-app-mydomain-com
173-# gordon-api-mydomain-com
174-# gordon-backend
17539 ```
17640
17741 Inspect a network:
17842
17943 ```bash
180-docker network inspect gordon-app-mydomain-com
44+docker network inspect <network-name>
18145 ```
18246
18347 ## Related
18448
185-- [Network Groups](./network-groups.md)
186-- [Attachments](./attachments.md)
49+- [App Manifest](./apps.md)
18750 - [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 +65 -66
@@ -83,12 +83,9 @@ max_size = 100 # Max size in MB before rotation
8383 max_backups = 3 # Number of old files to keep
8484 max_age = 28 # Days to keep old files
8585
86-[logging.container_logs]
87-enabled = true # Enable container log collection
88-dir = "" # Log directory (default: {data_dir}/container-logs)
89-max_size = 100 # Max size in MB before rotation
90-max_backups = 3 # Number of old files to keep
91-max_age = 28 # Days to keep old files
86+# Workload logs are streamed from the container runtime with
87+# `gordon apps logs APP --service SERVICE`. The accepted
88+# logging.container_logs fields are not connected to a production file sink.
9289
9390 [logging.access_log]
9491 enabled = false # Dedicated HTTP access log for reverse-proxy traffic
@@ -110,45 +107,21 @@ endpoint = "" # OTLP HTTP endpoint URL
110107 auth_token = "" # Base64-encoded user:password for Basic auth
111108 traces = true # Export distributed traces
112109 metrics = true # Export metrics
113-logs = true # Bridge zerolog output to OTLP logs
110+logs = true # Export Gordon, access, and app logs
114111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
115112
116-# =============================================================================
117-# ENVIRONMENT
118-# =============================================================================
119-[env]
120-dir = "" # Env files directory (default: {data_dir}/env)
121-
122-# =============================================================================
123-# DEPLOYMENT
124-# =============================================================================
125-[deploy]
126-pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"
127-readiness_mode = "auto" # "auto", "docker-health", "delay"
128-health_timeout = "90s" # Max wait for health-based readiness
129-readiness_delay = "5s" # Wait after running before considered ready
130-drain_mode = "auto" # "auto", "inflight", "delay"
131-drain_timeout = "30s" # Max wait for in-flight request drain
132-drain_delay = "2s" # Wait after cache invalidation before old stop
133-
134113 # =============================================================================
135114 # CONTAINERS
136115 # =============================================================================
137116 [containers]
138117 security_profile = "compat" # "compat" or "strict"
139118
140-# =============================================================================
141-# AUTO-ROUTE
142-# =============================================================================
143-[auto_route]
144-enabled = false # Create routes from image labels automatically
145-
146119 # =============================================================================
147120 # NETWORK ISOLATION
148121 # =============================================================================
149122 [network_isolation]
150-enabled = true # Enable per-app Docker networks
151-network_prefix = "gordon" # Prefix for created networks
123+enabled = true # Installation network policy for Gordon-managed networks
124+network_prefix = "gordon" # Prefix filter for `gordon daemon networks`
152125 internal = false # Create Docker internal networks (blocks direct egress)
153126
154127 # =============================================================================
@@ -160,12 +133,38 @@ prefix = "gordon" # Volume name prefix
160133 preserve = true # Keep volumes when containers are removed
161134
162135 # =============================================================================
163-# ROUTES
136+# ADMINISTRATIVE APP BIND MOUNTS
137+# =============================================================================
138+# A named policy is the only way an app manifest may reference a host path.
139+# App manifests declare [[services.<name>.bind]] with name = "<mount>"; direct host
140+# paths are always rejected, and the policy's allowlists are exact and
141+# non-empty. read_only on the policy and readonly on the bind both force
142+# read-only: either side wins and a bind never weakens its policy.
143+# [app_mounts.app-logs]
144+# source = "/srv/gordon/host-logs" # Required; absolute, normalized
145+# read_only = true # Force read-only for this mount
146+# allowed_apps = ["metrics-agent"] # Required; exact, non-empty
147+# allowed_services = ["web"] # Required; exact, non-empty
148+# root = "/srv/gordon" # Optional; default: parent of source
149+
150+# =============================================================================
151+# ADMINISTRATIVE APP DEVICES (CDI)
164152 # =============================================================================
165-[routes]
166-# "domain.com" = { image = "image:tag" }
167-# "insecure.domain.com" = { image = "image:tag", https = false }
168-# Legacy "http://domain.com" keys are read for compatibility and rewritten on save.
153+# A named device grant is the only way an app manifest may request host
154+# devices. Manifests declare devices = ["<name>"]; raw /dev paths are
155+# always rejected, and the policy's allowlists are exact and non-empty.
156+# Aggregate "=all" CDI selectors are rejected: name every device
157+# explicitly. Device-bearing creates require Podman 5.4+ or Docker 28.3+
158+# with native CDI configured.
159+# [app_devices.transcode-gpu]
160+# cdi = ["example.com/gpu=GPU-device-uuid"] # Required; explicit, non-empty
161+# allowed_apps = ["video"] # Required; exact, non-empty
162+# allowed_services = ["transcoder"] # Required; exact, non-empty
163+
164+# REMOVED in v3: [routes], [attachments], [network_groups],
165+# app-like [[services]], [service_routes], [auto_route], [previews].
166+# Declare apps in standalone files (see ./apps.md). The standalone
167+# [[services]] table below stays valid for installation-level L4 workloads.
169168
170169 # =============================================================================
171170 # EXTERNAL ROUTES
@@ -195,18 +194,6 @@ preserve = true # Keep volumes when containers are
195194 # publish = "127.0.0.1:38016"
196195 # trusted_cidrs = ["100.64.0.0/10"]
197196
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-
210197 # =============================================================================
211198 # BACKUPS
212199 # =============================================================================
@@ -225,6 +212,8 @@ monthly = 0 # Keep N monthly backups per DB
225212 # IMAGES
226213 # =============================================================================
227214 [images]
215+# Defaults: docker.io/registry-1.docker.io, ghcr.io, quay.io, Gordon registry.
216+# Add private or other registries as exact hostname+port entries.
228217 allowed_registries = []
229218 require_digest = false
230219
@@ -265,7 +254,7 @@ keep_last = 3 # Keep N newest tags per repository
265254 | `tls.acme.email` | `""` | ACME account email when enabled |
266255 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
267256 | `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) |
257+| `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`) |
269258 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
270259 | `auth.token_expiry` | `"30d"` | 30 days |
271260 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
@@ -280,10 +269,6 @@ keep_last = 3 # Keep N newest tags per repository
280269 | `logging.file.max_size` | `100` | 100 MB |
281270 | `logging.file.max_backups` | `3` | Keep 3 old files |
282271 | `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 |
287272 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
288273 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
289274 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
@@ -297,23 +282,23 @@ keep_last = 3 # Keep N newest tags per repository
297282 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
298283 | `telemetry.traces` | `true` | Export distributed traces |
299284 | `telemetry.metrics` | `true` | Export metrics |
300-| `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |
285+| `telemetry.logs` | `true` | Export Gordon, proxy access, and app container logs to OTLP |
301286 | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |
302-| `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
303-| `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
304-| `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
305-| `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
306-| `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
307-| `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 |
309287 | `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 |
311288 | `network_isolation.enabled` | `true` | Network isolation enabled |
312289 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
313290 | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
314291 | `volumes.auto_create` | `true` | Auto-create volumes |
315292 | `volumes.prefix` | `"gordon"` | Volume prefix |
316293 | `volumes.preserve` | `true` | Keep volumes |
294+| `app_mounts.<name>.source` | none | Required host source of a named administrative bind; absolute and normalized. The only host path that mount may come from |
295+| `app_mounts.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this mount (no wildcard or empty-means-all form) |
296+| `app_mounts.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this mount |
297+| `app_mounts.<name>.read_only` | `false` | Force every bind resolved under this policy read-only; a manifest bind can never weaken it |
298+| `app_mounts.<name>.root` | parent of `source` | Optional administrative boundary the resolved source must stay under |
299+| `app_devices.<name>.cdi` | none | Required explicit, non-empty CDI device IDs granted under this logical name (no `=all` aggregate form) |
300+| `app_devices.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant (no wildcard or empty-means-all form) |
301+| `app_devices.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |
317302 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
318303 | `services[].image` | none | Container image for enabled standalone services |
319304 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
@@ -342,14 +327,27 @@ keep_last = 3 # Keep N newest tags per repository
342327 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
343328 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
344329 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
345-| `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected |
346-| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |
330+| `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. |
331+| `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
347332 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
348333 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
349334 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
350335
351336 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
352337
338+## App Manifest HTTP Interfaces
339+
340+`[[services.<name>.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)).
341+
342+| Key | Values | Default | Description |
343+|-----|--------|---------|-------------|
344+| `services.<name>.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network |
345+| `services.<name>.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` |
346+| `services.<name>.http[].port` | integer | none | Required container port for every interface |
347+| `services.<name>.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value |
348+
349+`visibility = "internal"` creates no proxy route, host reservation, certificate target, or host port publication. A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected, as are duplicate internal HTTP ports.
350+
353351 ## Environment Variables
354352
355353 All configuration options can be set via environment variables using the pattern:
@@ -419,5 +417,6 @@ gordon serve
419417 - [Telemetry](./telemetry.md)
420418 - [Network Isolation](./network-isolation.md)
421419 - [Volumes](./volumes.md)
420+- [App Manifest](./apps.md)
422421 - [Standalone Services](./services.md)
423422 - [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 +7 -63
@@ -1,6 +1,6 @@
11 # Secrets Configuration
22
3-Configure how Gordon stores and retrieves sensitive data.
3+Configure how Gordon stores and retrieves sensitive data: auth secrets such as `token_secret` (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`).
44
55 ## Configuration
66
@@ -52,12 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
5252 - Standard Unix tooling
5353 - Works with team GPG keys
5454
55-**Route secrets storage:**
56-- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
57-- Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
58-- Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest
59-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
60-
6155 ### SOPS
6256
6357 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -77,13 +71,6 @@ brew install sops # macOS
7771 sops secrets.yaml
7872 ```
7973
80-**Usage in env files:**
81-```bash
82-# ~/.gordon/env/app_mydomain_com.env
83-API_SECRET=${sops:secrets.yaml:api.secret}
84-DB_PASSWORD=${sops:secrets.yaml:database.password}
85-```
86-
8774 **Benefits:**
8875 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)
8976 - YAML/JSON file encryption
@@ -94,15 +81,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
9481 - Path traversal (`..`) is blocked
9582 - Only relative paths from your config directory are allowed
9683
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-
10684 ### Unsafe (Development Only)
10785
10886 Stores secrets as plain text files:
@@ -120,11 +98,6 @@ secrets_backend = "unsafe"
12098 │ └── token_secret
12199 ```
122100
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-
128101 **Usage:**
129102 ```bash
130103 # Create secret
@@ -134,25 +107,15 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret
134107
135108 > **Warning:** Only use for local development. Secrets are stored in plain text.
136109
137-## Secret Provider Syntax
138-
139-In environment files, reference secrets using provider syntax:
110+## App Secret Values
140111
141-### Pass Provider
112+App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass:
142113
143114 ```bash
144-# ${pass:<path>}
145-DATABASE_PASSWORD=${pass:myapp/database/password}
146-API_KEY=${pass:myapp/api-key}
115+gordon apps secrets set blog --service web DATABASE_URL=...
147116 ```
148117
149-### SOPS Provider
150-
151-```bash
152-# ${sops:<file>:<key.path>}
153-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
154-API_SECRET=${sops:production.yaml:api.secret.key}
155-```
118+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).
156119
157120 ## Examples
158121
@@ -182,29 +145,10 @@ enabled = false
182145 secrets_backend = "unsafe"
183146 ```
184147
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-
204148 ## Security Recommendations
205149
206150 1. **Production**: Always use `pass` or `sops` backend
207-2. **Never commit**: Don't commit unencrypted secrets to git
151+2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values)
208152 3. **Rotate regularly**: Regenerate tokens and passwords periodically
209153 4. **Least privilege**: Use separate secrets per environment
210154 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
@@ -212,5 +156,5 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret}
212156 ## Related
213157
214158 - [Authentication](./auth.md)
215-- [Environment Variables](./env.md)
159+- [App Manifest](./apps.md)
216160 - [Configuration Overview](./index.md)
M docs/config/security-hardening.md +75 -13
@@ -24,37 +24,79 @@ Container and deploy logs can include environment-derived output. Gordon gates l
2424 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
2525 ```
2626
27-- `/admin/logs` and deploy failure logs require `admin:logs:read`.
27+- `/admin/logs`, app failure diagnostics, and deploy failure logs require `admin:logs:read`.
2828 - `admin:status:read` does not grant log access.
29-- Common secret patterns are redacted before logs are returned.
29+- Operation errors returned to `admin:apps:read` callers are stable and log-free. Application diagnostics are a separate field, redacted of the affected service's resolved secret values before storage, and returned only to callers holding `admin:logs:read`. If a declared secret cannot be resolved for redaction, Gordon drops the diagnostics instead of storing unredacted output. Mutation responses never include application output.
30+- Process and container log sources remain operator-owned data. Gordon redacts common credential patterns when serving logs through its API, but this is defense in depth rather than proof that arbitrary application output is secret-free. Restrict filesystem, journal, runtime, backup, and `admin:logs:read` access accordingly.
3031
3132 ## Volume pruning scope
3233
33-Volume pruning only removes unused Docker volumes explicitly managed by Gordon (`gordon.managed=true`). It ignores unrelated Docker volumes even if they are unused.
34+Volume pruning removes a volume only when Gordon has a durable `released` ownership record, the runtime app/incarnation/service labels agree with that record, and no container mounts it. A `gordon.managed=true` label by itself is not deletion authority; retained, unknown, contradictory, and unrelated volumes survive.
35+
36+Use `gordon volumes prune --dry-run` before deletion. Do not substitute `docker volume prune` or an equivalent runtime command: runtime-native pruning bypasses Gordon's ownership and retention checks.
3437
3538 Use dedicated admin scopes:
3639
3740 - `admin:volumes:read` for listing volumes.
3841 - `admin:volumes:write` for prune operations.
3942
40-## Pass migration plaintext handling
43+## Administrative app bind mounts
44+
45+App manifests cannot name host paths. A host bind exists only when the operator declares a named policy in `gordon.toml`:
46+
47+```toml
48+[app_mounts.app-logs]
49+source = "/srv/gordon/host-logs"
50+read_only = true
51+allowed_apps = ["metrics-agent"] # exact, non-empty
52+allowed_services = ["web"] # exact, non-empty
53+root = "/srv/gordon" # optional boundary
54+```
55+
56+The manifest references only the policy name: `[[services.<name>.bind]] name = "app-logs"` with an absolute container `path`.
57+
58+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
59+- `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused.
60+- Read-only precedence: `read_only` on the policy or `readonly` on the manifest bind forces the mount read-only; a manifest never weakens its policy.
61+- Destinations must be absolute, normalized, and outside reserved container paths (`/`, `/proc`, `/sys`, `/dev`, `/boot`, and their children).
62+- The policy is re-resolved immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed.
63+- `gordon serve` reload republishes validated policies atomically. An edit that fails validation is rejected and the previous policies stay live.
64+- Gordon never creates, deletes, chowns, backs up, or prunes the host path; ownership, permissions, and backup remain the operator's responsibility.
65+
66+## Administrative app devices (CDI)
4167
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.
68+App manifests cannot name host devices. A device grant exists only when the operator declares a named policy in `gordon.toml`:
69+
70+```toml
71+[app_devices.transcode-gpu]
72+cdi = ["example.com/gpu=GPU-device-uuid"]
73+allowed_apps = ["video"] # exact, non-empty
74+allowed_services = ["transcoder"] # exact, non-empty
75+```
76+
77+The manifest references only the logical name: `devices = ["transcode-gpu"]`.
78+
79+- `cdi` holds explicit CDI device IDs. Raw `/dev` paths, unqualified names, and aggregate `=all` selectors are rejected.
80+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
81+- Gordon resolves logical names to CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution.
82+- The grant is re-resolved immediately before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys while the running service is untouched; changing a mapping never recreates a running container.
83+- Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.
84+- Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
4385
4486 ## External image registries
4587
46-Gordon's configured registry is always allowed. Explicit external registries must be allowlisted:
88+Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
4789
4890 ```toml
4991 [images]
50-allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"]
92+allowed_registries = ["registry.internal:5000"]
5193 require_digest = true
5294 ```
5395
54-- Empty `allowed_registries` rejects explicit external registries.
55-- `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected.
56-- `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.
57-- Include ports in allowlist entries when the registry uses a non-default port.
96+- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
97+- 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.
98+- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
99+- 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.
58100
59101 ## Smart TCP Raw Fallback
60102
@@ -95,7 +137,28 @@ enabled = true
95137 internal = true
96138 ```
97139
98-`internal = false` remains the compatibility default because some applications and attachments need direct egress during startup.
140+`internal = false` remains the compatibility default because some applications need direct egress during startup.
141+
142+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.
143+
144+## Readiness helper containers
145+
146+Probing an internal HTTP port uses one bounded helper container per readiness attempt, removed immediately after. Gordon force-removes the helper under an independent cleanup context, including on failure and timeout. There is no operator configuration for the helper; its image and limits are fixed.
147+
148+- The helper image is digest-pinned and multi-arch: `alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc`. The image must be pre-provisioned and available offline on the target host, because the probe never pulls.
149+- The helper attaches only to the target app's private network. It never joins a shared network or a host network.
150+- It never publishes a host port and has no mounts, volumes, secrets, environment, or runtime socket.
151+- It runs non-root (uid/gid `65534`) with a read-only root filesystem, all capabilities dropped, `no-new-privileges`, and bounded CPU, memory, and PIDs.
152+- It targets the exact inspected container IP on that network, never a service alias, and revalidates the container's execution start before trusting the result; a restarted generation is discarded.
153+- A target that listens only on its own loopback fails readiness.
154+- Helpers left behind by an abrupt daemon or host stop are reclaimed by the next probe once they are older than ten minutes; a live session is never touched.
155+
156+## Registry exposure
157+
158+- With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
159+- 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.
160+- 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.
161+- 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.
99162
100163 ## Container runtime profile
101164
@@ -114,6 +177,5 @@ Use `strict` for images designed to write only to mounted volumes and run withou
114177 - [Auth](./auth.md)
115178 - [Images](./images.md)
116179 - [Network Isolation](./network-isolation.md)
117-- [Deploy](./deploy.md)
118180 - [Volumes](./volumes.md)
119181 - [Reference](./reference.md)
M docs/config/server.md +8 -12
@@ -32,7 +32,7 @@ protocol = "smart_tcp"
3232 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
3333 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
3434 | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). |
35-| `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
35+| `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and state |
3636 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3737 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
3838 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
@@ -102,7 +102,7 @@ Challenge behavior:
102102
103103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
104104
105-Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to configured HTTPS routes. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
105+Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to app HTTPS hosts. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
106106
107107 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
108108
@@ -116,7 +116,7 @@ DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS vis
116116
117117 The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.
118118
119-Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
119+Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by app HTTPS hosts. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
120120
121121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
122122
@@ -128,7 +128,7 @@ When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (
128128
129129 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.
130130
131-On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
131+On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts are served without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
132132
133133 #### HTTP to HTTPS redirect
134134
@@ -187,7 +187,7 @@ gordon_domain = "gordon.mydomain.com"
187187 This domain is used for:
188188 - Docker login and image push/pull operations
189189 - Admin API access (`/admin/*` endpoints)
190-- CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
190+- CLI remote targeting (`gordon apps --remote https://gordon.mydomain.com`)
191191 - Authentication endpoints (`/auth/*`)
192192
193193 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
@@ -220,13 +220,9 @@ data_dir = "~/.gordon" # Default for user installations
220220 ├── registry/ # Container images and manifests
221221 │ ├── blobs/
222222 │ └── manifests/
223-├── env/ # Environment files per domain
224-│ ├── app_mydomain_com.env
225-│ └── api_mydomain_com.env
226-├── logs/ # Application and container logs
223+├── logs/ # Gordon process and file-based access logs
227224 │ ├── gordon.log
228-│ ├── proxy.log
229-│ └── containers/
225+│ └── access.log
230226 └── secrets/ # Secrets (unsafe backend only)
231227 ```
232228
@@ -430,6 +426,6 @@ sudo firewall-cmd --reload
430426
431427 - [Configuration Overview](./index.md)
432428 - [Installation Guide](../installation.md)
433-- [Routes Configuration](./routes.md)
429+- [App Manifest](./apps.md)
434430 - [Standalone Services](./services.md)
435431 - [Traffic Plane Configuration](./traffic.md)
M docs/config/services.md +1 -1
@@ -91,4 +91,4 @@ By default Gordon removes old or disabled service containers while preserving vo
9191
9292 - [Traffic Plane Configuration](./traffic.md)
9393 - [Configuration Reference](./reference.md)
94-- [CLI traffic status](../cli/traffic.md)
94+- [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic)
M docs/config/telemetry.md +54 -5
@@ -26,7 +26,7 @@ trace_sample_rate = 1.0
2626 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
2727 | `traces` | bool | `true` | Export distributed traces |
2828 | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |
29-| `logs` | bool | `true` | Bridge zerolog output to OTLP logs |
29+| `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP |
3030 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
3131
3232 ## How It Works
@@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled:
3535
3636 1. **Traces** -- Spans wrap critical operations: container deploy, image pull, registry manifest push, and proxy target resolution. The `otelhttp` middleware adds a span to every HTTP request on both the proxy and registry servers.
3737 2. **Metrics** -- Gordon records custom counters and histograms for deploys, container restarts, crash loops, managed container count, registry pushes, and event bus throughput.
38-3. **Logs** -- A zerowrap/otel hook bridges all structured log output to the OTLP log pipeline. Every log line automatically carries `trace_id` and `span_id` when emitted inside a traced request.
38+3. **Logs** -- Gordon exports three log families (see [Logs](#logs)).
3939
4040 When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.
4141
42+## Logs
43+
44+With `logs = true`, Gordon exports:
45+
46+| Family | Source | `service.name` |
47+|--------|--------|----------------|
48+| Gordon process logs | zerowrap output, with `trace_id`/`span_id` inside traced requests | `gordon` |
49+| Proxy access logs | Every proxied request, attributed to the app service owning the host | `<app>.<service>` (`gordon` for registry and unknown hosts) |
50+| App container logs | stdout and stderr of every running app service container | `<app>.<service>` |
51+
52+Every app record carries these resource attributes:
53+
54+| Attribute | Example | Use |
55+|-----------|---------|-----|
56+| `service.name` | `blog.web` | One exact service; unique across apps |
57+| `service.namespace` | `blog` | All services of one app |
58+| `gordon.app` | `blog` | Filter by app |
59+| `gordon.service` | `web` | Filter by service name across apps |
60+
61+Record attributes:
62+
63+- `log.type`: `access` or `container`
64+- `log.iostream`: `stdout` or `stderr` (container logs)
65+- Access logs: `http.request.method`, `http.response.status_code`, `server.address`, `url.path`, `client.address`, `request.id`, and more. Severity follows the status: `5xx` is `ERROR`, `4xx` is `WARN`, otherwise `INFO`.
66+
67+Loki indexes `service.name` and `service.namespace` as stream labels by default; the other attributes are structured metadata, filtered after the stream selector. Example queries in Grafana:
68+
69+```text
70+{service_namespace="blog"} # every log of the blog app
71+{service_name="blog.web"} | log_type="access" # access logs of one service
72+{service_namespace=~".+"} | gordon_service="web" # every "web" service, all apps
73+```
74+
75+Container output is exported from the moment Gordon starts, including the first lines of every container deployed afterwards. Output emitted while Gordon is stopped is not exported. Lines longer than 64 KiB are truncated. Export is buffered and never slows the proxy or the app: on overload, the oldest records are dropped.
76+
77+### Per-app opt-out
78+
79+App container logs are exported by default. Opt out in the [app manifest](./apps.md#telemetry), per app or per service; a service setting overrides the app setting:
80+
81+```toml
82+[telemetry]
83+logs = true # app default
84+
85+[services.db.telemetry]
86+logs = false # only "db" stops exporting
87+```
88+
89+The opt-out covers container output only; access logs of the app's hosts are still exported.
90+
4291 ## Endpoint URL
4392
4493 The `endpoint` field accepts a full URL. Gordon parses it to extract:
@@ -61,7 +110,7 @@ Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it a
61110
62111 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
63112
64-Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods:
113+Gordon itself is the platform, not an app, so the token does not go through `gordon apps secrets`. Store it with one of these methods:
65114
66115 | Method | How |
67116 |--------|-----|
@@ -98,9 +147,9 @@ Attributes: `domain`, `image`
98147 |--------|------|------|-------------|
99148 | `gordon.container.restarts` | Counter | - | Container restart count |
100149 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101-| `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
150+| `gordon.container.managed` | Gauge | - | Containers of running app services, read from ACTIVE app state at each export |
102151
103-Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
152+Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.managed` has no attributes; tell instances apart with the `host.name` resource attribute.
104153
105154 ### Registry
106155
M docs/config/traffic.md +6 -6
@@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive packets
44
55 ## Smart TCP Edge Entrypoint
66
7-Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, normal Gordon routes use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
7+Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, app HTTP hosts use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
88
99 ```toml
1010 [entrypoints.edge]
@@ -15,7 +15,7 @@ trusted_cidrs = []
1515
1616 Do not treat `entrypoints.edge.address` as an HTTP port or an HTTPS port. It is one TCP socket that sniffs each new connection and dispatches the original byte stream.
1717
18-Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve normal Gordon routes through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
18+Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve app HTTP hosts through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
1919
2020 ## Smart TCP Dispatch Order
2121
@@ -73,7 +73,7 @@ protocol = "tcp"
7373
7474 Service references use:
7575
76-- `route:<domain>` for configured HTTP routes
76+- `route:<domain>` for app HTTP hosts from ACTIVE state (merged with installation external routes)
7777 - `external_route:<domain>` for configured external HTTP routes
7878 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends
7979 - `service:<service>:<port-name>` for Gordon-managed standalone service backends
@@ -96,7 +96,7 @@ sni = "raw.example.com"
9696 service = "network_service:raw:tls"
9797 ```
9898
99-Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. HTTPS application routes that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
99+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. App HTTPS hosts that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
100100
101101 ## Raw TCP Fallback
102102
@@ -176,6 +176,6 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
176176 ## Related
177177
178178 - [Server Settings](./server.md)
179-- [Routes](./routes.md)
179+- [App Manifest](./apps.md)
180180 - [Standalone Services](./services.md)
181-- [CLI traffic status](../cli/traffic.md)
181+- [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic)
M docs/config/volumes.md +73 -112
@@ -1,165 +1,126 @@
11 # Volumes Configuration
22
3-Configure automatic persistent storage for containers.
3+Persistent app storage is declared in each app manifest. The installation-level `[volumes]` settings apply to non-app volume management and do not control declarative app storage.
44
5-## Configuration
5+## Declarative app volumes
6+
7+Services declare persistent mounts in the app manifest:
68
79 ```toml
8-[volumes]
9-auto_create = true
10-prefix = "gordon"
11-preserve = true
10+[[services.web.volume]]
11+name = "database-data"
12+path = "/var/lib/postgresql/data"
1213 ```
1314
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 |
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. Manifests never carry host paths, and sharing one volume between services is not supported. When an app must read an operator-approved host location, use a named administrative bind instead.
2116
22-## How It Works
17+## Administrative bind mounts
2318
24-Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives:
19+Manifests cannot name host paths directly. The operator declares a named policy in `gordon.toml`; the manifest only references the policy name:
2520
26-```dockerfile
27-FROM postgres:18
28-VOLUME ["/var/lib/postgresql/data"]
21+```toml
22+# gordon.toml
23+[app_mounts.app-logs]
24+source = "/srv/gordon/host-logs" # required; the only host path this mount may come from
25+read_only = true
26+allowed_apps = ["metrics-agent"] # required; exact, non-empty
27+allowed_services = ["web"] # required; exact, non-empty
28+# root = "/srv/gordon" # optional administrative boundary
2929 ```
3030
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-
5131 ```toml
52-[volumes]
53-preserve = true
32+# app manifest
33+[[services.web.bind]]
34+name = "app-logs" # must match [app_mounts.<name>]
35+path = "/var/lib/collector/host-logs" # container destination
36+readonly = true
5437 ```
5538
56-With `preserve = true`:
57-- Volumes persist when containers are updated
58-- Data survives container restarts
59-- Volumes remain even if container is removed
39+- `source` must be absolute, normalized, and resolve through symlinks to a regular file or directory that stays under `root`. When `root` is omitted, the source's parent directory is the boundary, so a symlinked source may resolve within it but never escape it.
40+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists; there is no wildcard, prefix, or empty-means-all form.
41+- Read-only precedence: `read_only` on the policy or `readonly` on the bind forces the resolved mount read-only. A bind never weakens its policy.
42+- Gordon re-resolves the policy immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed. `gordon serve` reload republishes validated policies atomically, and a failing edit keeps the previous policies live.
43+- Gordon never creates, copies, deletes, chowns, backs up, or prunes the host path; ownership and permissions stay with the operator.
6044
61-### Remove with Container
45+A read-only log collector is the typical use. Its own state is a named volume; the host logs are exposed through a bind:
6246
6347 ```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
48+name = "metrics-agent"
7249
73-## Examples
50+[services.collector]
51+image = "registry.example.com/metrics/collector:2.1.0"
7452
75-### Database Container
53+[[services.collector.volume]]
54+name = "collector-state"
55+path = "/var/lib/collector"
7656
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
57+[[services.collector.bind]]
58+name = "app-logs"
59+path = "/var/lib/collector/host-logs"
60+readonly = true
8361 ```
8462
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
63+Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind.
8964
90-### Application with Uploads
65+Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[services.<name>.volume]]` declaration maps it to a Gordon-owned volume; a `[[services.<name>.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path.
9166
92-```dockerfile
93-FROM node:18
94-WORKDIR /app
95-VOLUME ["/app/uploads", "/app/data"]
96-COPY . .
97-CMD ["npm", "start"]
98-```
67+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.
9968
100-Creates two volumes:
101-- `gordon-app-mydomain-com-app-uploads`
102-- `gordon-app-mydomain-com-app-data`
69+## Retention
10370
104-### Custom Prefix
71+Ordinary app operations retain data:
10572
106-```toml
107-[volumes]
108-prefix = "prod"
109-```
73+- deploy and restart reuse the service's volumes;
74+- removing a service retains its volumes;
75+- `gordon apps remove` retains volumes under the removed app's internal UUID;
76+- a new app that reuses the public name does not adopt retained volumes;
77+- administrative binds are unaffected by app operations: their host paths are never created, adopted, or retained by Gordon.
11078
111-Volume names become:
112-- `prod-app-mydomain-com-data`
113-- `prod-db-mydomain-com-var-lib-postgresql-data`
79+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.
11480
115-## Managing Volumes
81+## Manual migration
11682
117-### List Volumes
83+Gordon does not copy data between volumes. Stop every container that can write to the source, verify a backup, create an empty target volume, and perform the copy through the runtime so rootless UID/GID mappings are preserved. For Podman, a disposable helper container is the preferred generic method:
11884
11985 ```bash
120-docker volume ls | grep gordon
86+podman run --rm --pull=never --network=none \
87+ --volume "<source-volume>:/source:ro" \
88+ --volume "<target-volume>:/target" \
89+ docker.io/library/alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
90+ sh -c 'cp -a /source/. /target/'
12191 ```
12292
123-### Inspect Volume
93+Replace the literal `<...>` placeholders before running; they are quoted above so a shell cannot mistake them for redirection. The approved helper image is digest-pinned, must already be available locally, and runs without network access. If direct access to storage paths is unavoidable with rootless Podman, enter its user namespace:
12494
12595 ```bash
126-docker volume inspect gordon-app-mydomain-com-data
96+podman unshare cp -a "<source-path>"/. "<target-path>"/
12797 ```
12898
129-### Backup Volume
99+Do not use a plain root `cp` as the default procedure: host ownership IDs may not match the container's user namespace. Applications that rely on ACLs, extended attributes, sparse files, or database consistency need an application-specific export/restore or copy tool. Validate ownership and application behavior against the target, and retain the source volume until the migration is accepted.
130100
131-```bash
132-docker run --rm \
133- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \
134- -v $(pwd):/backup \
135- alpine tar -czf /backup/db-backup.tar.gz -C /data .
136-```
101+## Pruning
137102
138-### Restore Volume
103+Use Gordon's ownership-aware command:
139104
140105 ```bash
141-docker run --rm \
142- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \
143- -v $(pwd):/backup \
144- alpine tar -xzf /backup/db-backup.tar.gz -C /data
106+gordon volumes prune --dry-run
107+gordon volumes prune
145108 ```
146109
147-## Volume Cleanup
110+A volume is eligible only when all of these requirements hold:
148111
149-If you have orphaned volumes:
112+1. Gordon has a durable ownership record marking it `released`.
113+2. Runtime labels agree with the record's app, app incarnation UUID, and service.
114+3. No container mounts the volume.
150115
151-```bash
152-# List all gordon volumes
153-docker volume ls -f name=gordon
116+Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. Administrative binds are out of scope entirely: they are operator-owned host paths, no ownership record exists for them, and they are never candidates. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions.
154117
155-# Remove specific volume (warning: deletes data!)
156-docker volume rm gordon-old-app-data
118+> **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.
157119
158-# Prune unused volumes (be careful!)
159-docker volume prune
160-```
120+See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output.
161121
162122 ## Related
163123
164-- [Attachments](./attachments.md)
124+- [App Manifest](./apps.md)
125+- [Volumes CLI](../cli/volumes.md)
165126 - [Configuration Overview](./index.md)
M docs/deployment/generic-ci.md +22 -22
@@ -5,10 +5,10 @@ Deploy with Gordon from any CI/CD system.
55 ## Requirements
66
77 - Docker available on CI runner (for building images)
8-- Network access to your Gordon server (HTTPS)
8+- Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`
99 - Gordon binary (optional but recommended)
1010
11-## Recommended: gordon push
11+## Recommended: gordon images push
1212
1313 ### 1. Generate Deployment Token
1414
@@ -17,7 +17,7 @@ On your Gordon server:
1717 ```bash
1818 gordon auth token generate \
1919 --subject ci-deploy \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \
20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2121 --expiry 0
2222 ```
2323
@@ -40,9 +40,9 @@ chmod +x /usr/local/bin/gordon
4040
4141 # 2. Build, push, and deploy
4242 export GORDON_TOKEN="$GORDON_TOKEN"
43-gordon push --build \
43+gordon images push --build \
4444 --remote "$GORDON_REMOTE" \
45- --no-confirm
45+
4646 ```
4747
4848 Gordon auto-detects the version from CI environment variables:
@@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables:
5252 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
5353 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
5454 | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
55-| Other | `--tag` flag | `gordon push --tag v1.2.0` |
55+| Other | `--tag` flag | `gordon images push --tag v1.2.0` |
5656
5757 ## Alternative: docker push
5858
@@ -69,7 +69,7 @@ docker push gordon.example.com/myapp:v1.0.0
6969 ```
7070
7171 This requires the token subject (`ci-deploy`) as the username.
72-Gordon auto-deploys when it receives the image.
72+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
7373
7474 ## CI System Examples
7575
@@ -89,7 +89,7 @@ pipeline {
8989 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
9090 -o /usr/local/bin/gordon
9191 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE" --no-confirm
92+ gordon images push --build --remote "$GORDON_REMOTE"
9393 '''
9494 }
9595 }
@@ -119,9 +119,8 @@ jobs:
119119 - run:
120120 name: Deploy
121121 command: |
122- gordon push --build \
123- --remote "$GORDON_REMOTE" \
124- --no-confirm
122+ gordon images push --build \
123+ --remote "$GORDON_REMOTE"
125124
126125 workflows:
127126 deploy:
@@ -153,7 +152,7 @@ steps:
153152 - apk add --no-cache curl
154153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155154 - chmod +x /usr/local/bin/gordon
156- - gordon push --build --remote "$GORDON_REMOTE" --no-confirm
155+ - gordon images push --build --remote "$GORDON_REMOTE"
157156
158157 trigger:
159158 event:
@@ -162,11 +161,13 @@ trigger:
162161
163162 ## Token Scopes Reference
164163
164+Use the minimum scopes for the steps the pipeline performs. A repository restriction limits registry access only; it does not narrow `admin:*` permissions.
165+
165166 | Workflow | Required Scopes |
166167 |----------|----------------|
167-| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |
168-| Push only (no deploy) | `push,pull,admin:routes:read` |
169-| docker push (auto-deploy) | `push,pull` |
168+| Build + push, then apply + deploy | `push,pull,admin:apps:read,admin:apps:write` |
169+| Push only (no deploy) | `push,pull` |
170+| docker push, then apply + deploy | `push,pull` + `admin:apps:read,admin:apps:write` for the CLI step |
170171
171172 ## Troubleshooting
172173
@@ -174,21 +175,20 @@ trigger:
174175
175176 The token is invalid or has been revoked. Generate a new one.
176177
177-### "no route configured for image"
178+### "deploy target not found" / app has no desired state
178179
179-The route must exist before pushing. Create it with:
180+Push only stores the image. Declare the app first:
180181
181182 ```bash
182-gordon routes add myapp.example.com myapp
183-# or for first deploy:
184-gordon bootstrap myapp.example.com myapp
183+gordon apps apply --file myapp.toml --remote "$GORDON_REMOTE"
184+gordon apps deploy myapp --remote "$GORDON_REMOTE"
185185 ```
186186
187187 ### Version shows "latest"
188188
189189 Gordon could not detect a version tag. Either:
190190 - Tag your git repo: `git tag v1.0.0 && git push --tags`
191-- Pass explicitly: `gordon push --tag v1.0.0`
191+- Pass explicitly: `gordon images push --tag v1.0.0`
192192
193193 ### Large images time out
194194
@@ -200,4 +200,4 @@ Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take
200200 - [GitLab CI](./gitlab-ci.md)
201201 - [Deployment Overview](./index.md)
202202 - [Authentication](../config/auth.md)
203-- [Push Command](../cli/push.md)
203+- [Images Commands](../cli/images.md#gordon-images-push)
M docs/deployment/github-actions.md +20 -24
@@ -1,16 +1,16 @@
11 # GitHub Actions Deployment
22
3-Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action.
3+Two approaches for deploying with GitHub Actions: the `gordon images push` CLI (recommended) or a Docker-based workflow using Gordon's official action.
44
55 ## Prerequisites
66
7-1. Gordon server running with registry authentication enabled
8-2. Deployment token generated with the required scopes
7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8+2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps
99 3. GitHub repository secrets configured
1010
11-## Recommended: gordon push
11+## Recommended: gordon images push
1212
13-Use the `gordon push` CLI for a lightweight, single-step build and deploy.
13+Use the `gordon images push` CLI for a lightweight, single-step build and deploy.
1414
1515 ### 1. Generate Token
1616
@@ -19,7 +19,7 @@ On your Gordon server:
1919 ```bash
2020 gordon auth token generate \
2121 --subject github-actions \
22- --scopes "push,pull,admin:routes:read,admin:config:write" \
22+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2323 --expiry 0
2424 ```
2525
@@ -71,9 +71,8 @@ jobs:
7171 env:
7272 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
7373 run: |
74- gordon push --build \
75- --remote ${{ secrets.GORDON_REMOTE }} \
76- --no-confirm
74+ gordon images push --build \
75+ --remote ${{ secrets.GORDON_REMOTE }}
7776 ```
7877
7978 #### Continuous Deploy on Main
@@ -103,10 +102,9 @@ jobs:
103102 env:
104103 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
105104 run: |
106- gordon push --build \
105+ gordon images push --build \
107106 --remote ${{ secrets.GORDON_REMOTE }} \
108- --tag latest \
109- --no-confirm
107+ --tag latest
110108 ```
111109
112110 #### Manual Dispatch
@@ -141,7 +139,7 @@ jobs:
141139 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142140 DEPLOY_TAG: ${{ inputs.tag }}
143141 run: |
144- args=(push --build --remote "$GORDON_REMOTE" --no-confirm)
142+ args=(push --build --remote "$GORDON_REMOTE")
145143 if [[ -n "$DEPLOY_TAG" ]]; then
146144 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
147145 args+=(--tag "$DEPLOY_TAG")
@@ -151,7 +149,7 @@ jobs:
151149
152150 #### Monorepo
153151
154-Deploy multiple services with separate `gordon push` calls:
152+Deploy multiple services with separate `gordon images push` calls:
155153
156154 ```yaml
157155 name: Deploy Services
@@ -173,21 +171,19 @@ jobs:
173171 env:
174172 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
175173 run: |
176- gordon push --build \
174+ gordon images push --build \
177175 --remote ${{ secrets.GORDON_REMOTE }} \
178176 --file ./services/api/Dockerfile \
179- myapp-api \
180- --no-confirm
177+ myapp-api
181178
182179 - name: Deploy Web
183180 env:
184181 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
185182 run: |
186- gordon push --build \
183+ gordon images push --build \
187184 --remote ${{ secrets.GORDON_REMOTE }} \
188185 --file ./services/web/Dockerfile \
189- myapp-web \
190- --no-confirm
186+ myapp-web
191187 ```
192188
193189 #### With Build Args
@@ -199,12 +195,12 @@ Pass build arguments to the Docker build:
199195 env:
200196 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
201197 run: |
202- gordon push --build \
198+ gordon images push --build \
203199 --remote ${{ secrets.GORDON_REMOTE }} \
204200 --build-arg NODE_ENV=production \
205201 --build-arg API_URL=https://api.example.com \
206202 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
207- --no-confirm
203+
208204 ```
209205
210206 ### setup-gordon Action Reference
@@ -220,7 +216,7 @@ Pass build arguments to the Docker build:
220216
221217 ## Alternative: Docker-based Workflow
222218
223-For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed.
219+For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Pushing only stores the image — deploy explicitly with `gordon apps deploy` afterwards.
224220
225221 ### Setup
226222
@@ -482,5 +478,5 @@ Error: unauthorized: authentication required
482478 - [Generic CI](./generic-ci.md)
483479 - [Deployment Overview](./index.md)
484480 - [Authentication](../config/auth.md)
485-- [Push Command](../cli/push.md)
481+- [Images Commands](../cli/images.md#gordon-images-push)
486482 - [Rollback](./rollback.md)
M docs/deployment/gitlab-ci.md +15 -15
@@ -4,8 +4,8 @@ Automated deployment with GitLab CI/CD using Gordon's push command.
44
55 ## Prerequisites
66
7-1. Gordon server running with registry authentication enabled
8-2. Generated deployment token
7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8+2. A least-privilege token: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the pipeline applies or deploys apps
99 3. GitLab CI/CD variables configured
1010
1111 ## Quick Setup
@@ -17,7 +17,7 @@ On your Gordon server:
1717 ```bash
1818 gordon auth token generate \
1919 --subject gitlab-ci \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \
20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
2121 --expiry 0
2222 ```
2323
@@ -45,10 +45,10 @@ deploy:
4545 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
4646 - chmod +x /usr/local/bin/gordon
4747 script:
48- - gordon push --build
48+ - gordon images push --build
4949 --remote "$GORDON_REMOTE"
5050 --token "$GORDON_TOKEN"
51- --no-confirm
51+
5252 only:
5353 - tags
5454 ```
@@ -73,10 +73,10 @@ deploy:
7373 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
7474 - chmod +x /usr/local/bin/gordon
7575 script:
76- - gordon push --build
76+ - gordon images push --build
7777 --remote "$GORDON_REMOTE"
7878 --token "$GORDON_TOKEN"
79- --no-confirm
79+
8080 rules:
8181 - if: $CI_COMMIT_TAG
8282 ```
@@ -96,10 +96,10 @@ deploy:
9696 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
9797 - chmod +x /usr/local/bin/gordon
9898 script:
99- - gordon push --build
99+ - gordon images push --build
100100 --remote "$GORDON_REMOTE"
101101 --token "$GORDON_TOKEN"
102- --no-confirm
102+
103103 rules:
104104 - if: $CI_COMMIT_BRANCH == "main"
105105 ```
@@ -117,10 +117,10 @@ deploy:
117117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118118 - chmod +x /usr/local/bin/gordon
119119 script:
120- - gordon push --build
120+ - gordon images push --build
121121 --remote "$GORDON_REMOTE"
122122 --token "$GORDON_TOKEN"
123- --no-confirm
123+
124124 when: manual
125125 ```
126126
@@ -137,12 +137,12 @@ deploy:
137137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138138 - chmod +x /usr/local/bin/gordon
139139 script:
140- - gordon push --build
140+ - gordon images push --build
141141 --remote "$GORDON_REMOTE"
142142 --token "$GORDON_TOKEN"
143143 --build-arg NODE_ENV=production
144144 --build-arg API_URL=https://api.example.com
145- --no-confirm
145+
146146 rules:
147147 - if: $CI_COMMIT_TAG
148148 ```
@@ -166,7 +166,7 @@ deploy:
166166 ```
167167
168168 This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.
169-Gordon auto-deploys when it receives the image.
169+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
170170
171171 ## Version Detection
172172
@@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th
208208 - [Generic CI](./generic-ci.md)
209209 - [Deployment Overview](./index.md)
210210 - [Authentication](../config/auth.md)
211-- [Push Command](../cli/push.md)
211+- [Images Commands](../cli/images.md#gordon-images-push)
M docs/deployment/index.md +45 -73
@@ -1,78 +1,68 @@
11 # Deployment Overview
22
3-Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure.
3+Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests.
44
5-## Recommended: gordon push
5+## Recommended: gordon images push + apps deploy
66
7-`gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines.
7+`gordon images push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI.
88
9-- Single command: build + push + deploy
109 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
1110 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
1211 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
1312
1413 ```bash
15-gordon push --build --remote https://gordon.example.com --no-confirm
14+gordon images push --build --remote https://gordon.example.com
15+```
16+
17+Then activate (from CI with the Gordon binary, or from your machine):
18+
19+```bash
20+gordon apps apply --file blog.toml --remote https://gordon.example.com
21+gordon apps deploy blog --remote https://gordon.example.com
1622 ```
1723
1824 ## All Deployment Methods
1925
2026 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
2127 |--------|----------|----------------|-----------------|----------------|
22-| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
23-| `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) |
24-| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
28+| `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
29+| `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
2530
26-### Method 1: gordon push (Recommended)
31+### Method 1: gordon images push + apps deploy (Recommended)
2732
28-The Gordon CLI handles authentication, image building, and deployment in a single step.
33+The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.
2934
3035 - Single token handles everything: admin API access + registry auth via automatic token exchange
3136 - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`
32-- Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment)
3337 - Requires the Gordon binary on the CI runner
3438
3539 ```bash
36-# Build, push, and deploy
37-gordon push --build --remote https://gordon.example.com --no-confirm
40+# Build and push (OCI transfer only)
41+gordon images push --build --remote https://gordon.example.com
3842
39-# Push only, no deploy
40-gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm
43+# Apply the manifest that references the pushed tag, then deploy
44+gordon apps apply --file blog.toml --remote https://gordon.example.com
45+gordon apps deploy blog --remote https://gordon.example.com
4146 ```
4247
43-### Method 2: docker push
48+### Method 2: docker push + apps deploy
4449
45-Standard Docker workflow — no Gordon binary required on the runner.
50+Standard Docker workflow — no Gordon binary required on the runner for the push itself.
4651
4752 - Use `docker login`, `docker build`, and `docker push` as usual
48-- Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed)
49-- No Gordon binary needed on the CI runner
53+- Pushing only stores the image; deploy explicitly with the Gordon CLI afterwards
5054 - Registry endpoint is `gordon.example.com` (not a separate registry host)
5155
5256 ```bash
5357 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
5458 docker build -t gordon.example.com/myapp:v1.2.0 .
5559 docker push gordon.example.com/myapp:v1.2.0
60+# Then: gordon apps apply --file blog.toml + gordon apps deploy blog
5661 ```
5762
58-### Method 3: Docker labels + auto-route
59-
60-Add a `gordon.domain` label to your image and Gordon creates the route automatically on push.
61-
62-- Add `gordon.domain=app.example.com` label to your Dockerfile
63-- Push image — Gordon creates or updates the route without manual config
64-- Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml`
65-- Best for GitOps workflows where routes are defined alongside the application
66-
67-```dockerfile
68-LABEL gordon.domain="app.example.com"
69-```
70-
71-See [Auto-Route](../config/auto-route.md) for configuration details.
72-
7363 ## Registry Access
7464
75-Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`.
65+Clients use the main Gordon domain over HTTPS, normally on port 443. Gordon binds `server.registry_port` to `127.0.0.1`; the public edge proxies authenticated registry requests to that loopback listener. Do not publish or forward the loopback registry port. With `auth.enabled=false`, registry access is loopback-only and the public edge refuses registry-domain requests.
7666
7767 ### Network Topologies
7868
@@ -87,34 +77,27 @@ Gordon's registry is served through the main gordon domain over HTTPS on port 44
8777 See the examples below for the right scopes for each workflow.
8878
8979 ```bash
90-# Minimum scope for gordon push — route lookup + registry push
91-# Server auto-deploys when it receives the image
80+# Push only — registry scopes
9281 gordon auth token generate \
9382 --subject ci-bot \
94- --scopes "push,pull,admin:routes:read" \
83+ --scopes "push,pull" \
9584 --expiry 90d
9685
97-# With explicit CLI-managed deploy (adds config:write for deploy control)
86+# Push + apply/deploy — adds app mutation scopes
9887 gordon auth token generate \
9988 --subject ci-bot \
100- --scopes "push,pull,admin:routes:read,admin:config:write" \
89+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
10190 --expiry 90d
10291
103-# Scoped to a specific repository
92+# Repository-scoped registry token for push only
10493 gordon auth token generate \
10594 --subject ci-bot \
10695 --repo myapp \
107- --scopes "push,pull,admin:routes:read" \
108- --expiry 90d
109-
110-# For docker push — registry scopes only
111-gordon auth token generate \
112- --subject ci-bot \
11396 --scopes "push,pull" \
11497 --expiry 90d
11598 ```
11699
117-Set the generated token as `GORDON_TOKEN` in your CI environment.
100+Set the generated token as `GORDON_TOKEN` in your CI environment. `--repo` limits registry push/pull to that repository; it does not constrain `admin:*` scopes. Use a separate least-privilege token for app administration when repository and deployment duties must be isolated.
118101
119102 ## Version Strategies
120103
@@ -127,26 +110,23 @@ docker tag myapp gordon.example.com/myapp:latest
127110 docker push gordon.example.com/myapp:latest
128111 ```
129112
113+Reference it from the app manifest:
114+
130115 ```toml
131-[routes]
132-"app.example.com" = "myapp:latest"
116+[services.web]
117+image = "gordon.example.com/myapp:latest"
133118 ```
134119
135120 ### Semantic Versioning
136121
137-Pin routes to specific versions and update config to roll forward:
122+Pin services to specific versions and apply a new manifest to roll forward:
138123
139124 ```bash
140125 docker tag myapp gordon.example.com/myapp:v2.1.0
141126 docker push gordon.example.com/myapp:v2.1.0
142127 ```
143128
144-```toml
145-[routes]
146-"app.example.com" = "myapp:v2.1.0"
147-```
148-
149-To deploy a new version, update the tag in `gordon.toml` and push the new image.
129+To deploy a new version, update the tag in the app file, apply, and deploy.
150130
151131 ### Git SHA Tags
152132
@@ -158,30 +138,22 @@ docker tag myapp gordon.example.com/myapp:$VERSION
158138 docker push gordon.example.com/myapp:$VERSION
159139 ```
160140
161-## Zero-Downtime Deployment
162-
163-Gordon performs zero-downtime deployments by default:
141+## Updates
164142
165-1. **New container starts** while the old one is still running
166-2. **Health check** waits for the new container to become ready
167-3. **Traffic switches** to the new container
168-4. **Old container stops** after traffic moves
143+Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time.
169144
170145 ```
171-Timeline ─────────────────────────────────────────>
172-
173-Old Container: [═══════════════════]
174- ↓ stop
175-New Container: [═════════════════════════>
176- ↑ start ↑ traffic routed
146+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
177147 ```
178148
149+Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back.
150+
151+If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default.
152+
179153 ## Related
180154
181155 - [GitHub Actions](./github-actions.md)
182156 - [GitLab CI](./gitlab-ci.md)
183157 - [Generic CI](./generic-ci.md)
184-- [Rollback](./rollback.md)
185-- [Routes Configuration](../config/routes.md)
158+- [Apps CLI](../cli/apps.md)
186159 - [Authentication](../config/auth.md)
187-- [Auto-Route](../config/auto-route.md)
M docs/deployment/rollback.md +28 -175
@@ -1,62 +1,28 @@
11 # Rollback
22
3-Roll back to previous versions when deployments fail.
3+Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v3: rolling back means applying and deploying a previous manifest or image reference. Redeploying a mutable tag (for example `latest`) does not guarantee the previous image bytes; keep immutable versioned tags.
44
5-## Recommended: Use `gordon pin`
5+## Roll Forward to a Previous Tag
66
7-`gordon pin` is the built-in rollback and roll-forward workflow for route images:
7+The image tags are still in the registry. Point the app file at the last good tag, apply, deploy:
88
99 ```bash
10-gordon pin app.example.com
11-gordon pin app.example.com --tag v2.30.1
12-gordon pin list app.example.com
10+# 1. Edit blog.toml: image = "gordon.mydomain.com/myapp:v2.0.0"
11+gordon apps apply --file blog.toml --remote prod
12+gordon apps deploy blog --remote prod
1313 ```
1414
15-Use it when the target image already exists in the Gordon registry and you want Gordon to update the route and redeploy it for you.
15+A rollback deploy replaces services one at a time like any other deploy: a short service interruption is possible, and Gordon never runs two generations of the same service at the same time. If the replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback.
1616
17-## Rollback Strategies
17+## Re-push the Previous Image as latest
1818
19-### 1. Config-Based Rollback
20-
21-Update the route to point to the previous version:
22-
23-```toml
24-# Current (broken)
25-[routes]
26-"app.mydomain.com" = "myapp:v2.1.0"
27-
28-# Rollback to previous
29-[routes]
30-"app.mydomain.com" = "myapp:v2.0.0"
31-```
32-
33-Then reload:
34-
35-```bash
36-gordon reload
37-```
38-
39-### 2. Push Previous Version
40-
41-Re-push the previous image with the `latest` tag:
19+When the app file tracks `latest`, re-tag and re-push, then deploy (deploy re-resolves mutable tags):
4220
4321 ```bash
44-docker pull registry.mydomain.com/myapp:v2.0.0
45-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest
46-docker push registry.mydomain.com/myapp:latest
47-```
48-
49-### 3. Manifest-Based Rollback
50-
51-Use OCI manifest annotations for version control:
52-
53-```bash
54-# Rollback to v2.0.0
55-export VERSION=v2.0.0
56-podman manifest create myapp:latest --amend
57-podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
58-podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
59-podman manifest push myapp:latest registry.mydomain.com/myapp:latest
22+docker pull gordon.mydomain.com/myapp:v2.0.0
23+docker tag gordon.mydomain.com/myapp:v2.0.0 gordon.mydomain.com/myapp:latest
24+docker push gordon.mydomain.com/myapp:latest
25+gordon apps deploy blog --remote prod
6026 ```
6127
6228 ## Version Management
@@ -67,169 +33,56 @@ Always push versioned tags alongside `latest`:
6733
6834 ```bash
6935 VERSION=$(git describe --tags)
70-docker tag myapp registry.mydomain.com/myapp:$VERSION
71-docker tag myapp registry.mydomain.com/myapp:latest
72-docker push registry.mydomain.com/myapp:$VERSION
73-docker push registry.mydomain.com/myapp:latest
36+docker tag myapp gordon.mydomain.com/myapp:$VERSION
37+docker tag myapp gordon.mydomain.com/myapp:latest
38+docker push gordon.mydomain.com/myapp:$VERSION
39+docker push gordon.mydomain.com/myapp:latest
7440 ```
7541
7642 ### Semantic Versioning
7743
78-Use semantic versions for clear rollback targets:
44+Use semantic versions for clear recovery targets:
7945
8046 ```
8147 v2.1.0 ← Current (broken)
82-v2.0.0 ← Rollback target
48+v2.0.0 ← Recovery target
8349 v1.9.0 ← Older stable
8450 ```
8551
8652 ### Git SHA Tags
8753
88-Tag with commit SHA for precise rollbacks:
54+Tag with commit SHA for precise recovery:
8955
9056 ```bash
9157 # Deploy
9258 SHA=$(git rev-parse --short HEAD)
93-docker push registry.mydomain.com/myapp:$SHA
94-
95-# Rollback to specific commit
96-docker pull registry.mydomain.com/myapp:abc1234
97-docker tag registry.mydomain.com/myapp:abc1234 registry.mydomain.com/myapp:latest
98-docker push registry.mydomain.com/myapp:latest
99-```
100-
101-## Rollback Workflow
102-
103-### Quick Rollback
104-
105-```bash
106-# 1. Identify last working version
107-docker image ls registry.mydomain.com/myapp
108-
109-# 2. Tag as latest
110-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest
111-
112-# 3. Push
113-docker push registry.mydomain.com/myapp:latest
114-```
115-
116-### Config Rollback
117-
118-```bash
119-# 1. Edit config
120-vim ~/.config/gordon/gordon.toml
121-
122-# 2. Change version
123-# "app.mydomain.com" = "myapp:v2.0.0"
124-
125-# 3. Reload
126-gordon reload
127-```
128-
129-## Automated Rollback
130-
131-### GitHub Actions
132-
133-Add rollback capability to your workflow:
134-
135-```yaml
136-name: Rollback
137-
138-on:
139- workflow_dispatch:
140- inputs:
141- version:
142- description: 'Version to rollback to (e.g., v2.0.0)'
143- required: true
144-
145-jobs:
146- rollback:
147- runs-on: ubuntu-latest
148- steps:
149- - name: Login to Registry
150- env:
151- GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
152- GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }}
153- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
154- run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY"
155-
156- - name: Rollback
157- env:
158- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
159- VERSION: ${{ inputs.version }}
160- run: |
161- [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162- REPOSITORY="$GORDON_REGISTRY/myapp"
163- SOURCE_IMAGE="$REPOSITORY:$VERSION"
164- LATEST_IMAGE="$REPOSITORY:latest"
165- SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166- IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167- docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168- LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169- [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170- echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171- exit 1
172- }
173-
174- - name: Summary
175- env:
176- VERSION: ${{ inputs.version }}
177- run: |
178- echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
179- printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
180-```
181-
182-### Rollback Script
183-
184-Create a local rollback script:
185-
186-```bash
187-#!/bin/bash
188-# rollback.sh
189-
190-REGISTRY="registry.mydomain.com"
191-IMAGE="myapp"
192-VERSION=$1
193-
194-if [ -z "$VERSION" ]; then
195- echo "Usage: ./rollback.sh <version>"
196- echo "Available versions:"
197- docker image ls "$REGISTRY/$IMAGE" --format "{{.Tag}}"
198- exit 1
199-fi
200-
201-echo "Rolling back to $IMAGE:$VERSION..."
202-docker pull "$REGISTRY/$IMAGE:$VERSION"
203-docker tag "$REGISTRY/$IMAGE:$VERSION" "$REGISTRY/$IMAGE:latest"
204-docker push "$REGISTRY/$IMAGE:latest"
205-echo "Rollback complete!"
59+docker push gordon.mydomain.com/myapp:$SHA
20660 ```
20761
208-## Verifying Rollback
62+## Verifying Recovery
20963
210-After rollback:
64+After re-deploying the previous version:
21165
21266 ```bash
213-# Check container is running correct version
214-docker inspect gordon-app-mydomain-com | grep Image
67+# Check the app's effective vs observed state
68+gordon apps status blog --remote prod
21569
21670 # Check application responds
21771 curl -I https://app.mydomain.com
21872
21973 # Check logs
220-gordon logs -f
74+gordon apps logs blog --remote prod
22175 ```
22276
22377 ## Best Practices
22478
22579 1. **Always tag versions** - Don't rely solely on `latest`
226-2. **Keep N previous versions** - Maintain rollback options
80+2. **Keep N previous versions** - Maintain recovery options
22781 3. **Test before deploy** - Reduce need for rollbacks
22882 4. **Document known-good versions** - Track stable releases
229-5. **Automate rollback** - Reduce time to recovery
23083
23184 ## Related
23285
23386 - [Deployment Overview](./index.md)
23487 - [GitHub Actions](./github-actions.md)
235-- [Routes Configuration](../config/routes.md)
88+- [Apps CLI](../cli/apps.md)
M docs/getting-started.md +44 -33
@@ -12,7 +12,7 @@ Deploy your first app with Gordon in under 5 minutes.
1212 ## 1. Install Gordon
1313
1414 ```bash
15-curl -fsSL https://gordon.bnema.dev/install | bash
15+curl -fsSL https://bnema.dev/gordon/install | bash
1616 ```
1717
1818 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.
@@ -79,11 +79,10 @@ gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
7979 [entrypoints.edge]
8080 address = ":443" # Public smart TCP edge (choose your bind/mapping)
8181 protocol = "smart_tcp"
82-
83-[routes]
84-"app.mydomain.com" = "myapp:latest" # Domain → Image mapping
8582 ```
8683
84+Application workloads are NOT declared in `gordon.toml`. Each app lives in its own standalone TOML file (see step 8). The old `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` keys were removed in v3 — Gordon refuses to start when any of them is present. Installation-level `[[services]]` for standalone L4 workloads remains valid.
85+
8786 ## 5. Set Up DNS (Including Wildcard)
8887
8988 `gordon_domain` is the single domain used by both the registry and admin API.
@@ -96,8 +95,8 @@ In Cloudflare (or your DNS provider), create:
9695 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
9796
9897 Why wildcard (`*`)?
99-- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
100-- You can add new domains in `[routes]` without creating DNS records one by one.
98+- It automatically covers app hosts like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
99+- You can add new app HTTP hosts without creating DNS records one by one.
101100
102101 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
103102
@@ -129,10 +128,10 @@ sudo loginctl enable-linger $USER
129128
130129 ## 7. Generate a Deploy Token
131130
132-Create a token for remote CLI deploys (skip if auth is disabled):
131+Create a token for remote CLI use (skip if auth is disabled):
133132
134133 ```bash
135-gordon auth token generate --subject deploy --scopes push,pull --expiry 0
134+gordon auth token generate --subject deploy --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 90d
136135 ```
137136
138137 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.
@@ -148,53 +147,65 @@ On your local machine:
148147 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
149148 gordon remotes use prod
150149
151-# Recommended first-time setup
152-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
153-
154-# Then build, push, and deploy
155-gordon push myapp:latest --domain app.example.com --build --no-confirm
150+# Build and push the image (OCI transfer only, never deploys)
151+gordon images push myapp --build --remote prod
156152 ```
157153
158-What this command does:
154+Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.
159155
160-- `gordon bootstrap` creates or updates the route, applies requested attachments,
161- and stores environment variables.
162-- This is the recommended path for first deploys because it does not require the
163- route to exist ahead of time.
164-- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.
156+```toml
157+name = "blog"
158+
159+[services.web]
160+image = "gordon.mydomain.com/myapp:latest"
161+
162+[[services.web.http]]
163+host = "app.mydomain.com"
164+port = 3000
165+```
165166
166-If the route already exists and you only need to push a new image version, use:
167+Apply the manifest, then deploy:
167168
168169 ```bash
169-gordon push myapp --build --no-confirm
170+gordon apps apply --file blog.toml --remote prod
171+gordon apps deploy blog --remote prod
170172 ```
171173
172-`gordon push` still requires the route to already exist so it can resolve the
173-deploy target.
174+What these commands do:
175+
176+- `gordon images push` builds, uploads, and stores the image. It never deploys.
177+- `gordon apps apply` validates the manifest and persists it as desired state.
178+- `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it.
179+
180+If the app needs secrets, register their names in the manifest (`[services.<name>.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file:
181+
182+```bash
183+gordon apps secrets set blog --service web APP_ENV=production --remote prod
184+```
174185
175186 Your app is now live at `https://app.mydomain.com`!
176187
177188 ## 9. Update Your App
178189
179-Push a new image to deploy with zero downtime:
190+Push a new image, update the manifest tag if needed, apply, deploy:
180191
181192 ```bash
182-# Make changes, then build + push + deploy
183-gordon push myapp --build --no-confirm
193+# Make changes, then build + push
194+gordon images push myapp --build --remote prod
195+
196+# Deploy the new tag (re-resolves mutable tags)
197+gordon apps deploy blog --remote prod
184198 ```
185199
186-Gordon automatically:
187-1. Starts the new container
188-2. Waits for it to be ready
189-3. Routes traffic to the new container
190-4. Stops the old container
200+Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services.
191201
192202 ## Next Steps
193203
194204 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers
195-- [Configuration Reference](./config/index.md) - All configuration options
205+- [Configuration Reference](./config/index.md) - All installation configuration options
206+- [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands
196207 - [Authentication](./config/auth.md) - Secure your registry
197-- [Environment Variables](./config/env.md) - Configure per-app settings
208+- [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass
198209
199210 ## Related
200211
M docs/index.md +10 -12
@@ -1,15 +1,16 @@
11 # Gordon Documentation
22
3-Gordon is a self-hosted container deployment platform that combines a private Docker registry with automatic container deployment.
3+Gordon is a self-hosted container deployment platform: a private container registry, a declarative app runtime, and a reverse proxy.
44
55 ## What is Gordon?
66
77 Gordon runs on your VPS and provides:
88
99 - **Private Container Registry** - Push images from your local machine or CI
10-- **HTTP Reverse Proxy** - Routes domains to containers automatically
11-- **Push-to-Deploy** - Containers deploy when you push new images
12-- **Zero-Downtime Updates** - New containers start before old ones stop
10+- **Declarative Apps** - One TOML file per app, explicit apply then deploy
11+- **HTTP Reverse Proxy** - Routes app hosts to containers
12+- **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13+- **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish
1314 - **Single Binary** - ~15MB RAM footprint
1415
1516 ## How It Works
@@ -31,7 +32,7 @@ Gordon runs on your VPS and provides:
3132
3233 1. Build your container locally where you have computing power
3334 2. Push to your Gordon registry
34-3. Gordon automatically deploys and routes traffic to your container
35+3. Apply the app manifest and deploy to activate it
3536
3637 ## Quick Navigation
3738
@@ -46,15 +47,13 @@ Gordon runs on your VPS and provides:
4647
4748 - [Configuration Overview](./config/index.md) - All configuration options
4849 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49-- [Routes](./config/routes.md) - Domain to container mapping
50+- [App Manifest](./config/apps.md) - Declarative app files (services, hosts, secrets, volumes)
51+- [Migrate to Gordon v3](./migrate-to-v3.md) - Breaking upgrade and explicit secret migration
5052 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
5153 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
52-- [Secrets](./config/secrets.md) - Secure credential storage
53-- [Network Isolation](./config/network-isolation.md) - Per-app network isolation
54-- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
55-- [Attachments](./config/attachments.md) - Service dependencies
54+- [Secrets](./config/secrets.md) - Installation secrets and app secret values
55+- [Network Isolation](./config/network-isolation.md) - Installation network policy
5656 - [Logging](./config/logging.md) - Log collection and rotation
57-- [Environment Variables](./config/env.md) - Per-route environment configuration
5857
5958 ### CLI Reference
6059
@@ -71,7 +70,6 @@ Gordon runs on your VPS and provides:
7170 ### Reference
7271
7372 - [Docker Labels](./reference/docker-labels.md) - Container and image labels
74-- [Environment Variables](./reference/env-variables.md) - Environment variable syntax
7573 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions
7674
7775 ## Requirements
M docs/installation.md +41 -12
@@ -19,7 +19,7 @@ Detailed installation guide for production environments.
1919 set -euo pipefail
2020 installer="$(mktemp)"
2121 trap 'rm -f "$installer"' EXIT
22- curl -fsSL --output "$installer" https://gordon.bnema.dev/install
22+ curl -fsSL --output "$installer" https://bnema.dev/gordon/install
2323 if command -v less >/dev/null 2>&1; then
2424 less "$installer"
2525 else
@@ -29,7 +29,25 @@ Detailed installation guide for production environments.
2929 )
3030 ```
3131
32-Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
32+Download and inspect the installer before executing it. The installer verifies the release archive checksum, detects Linux/macOS and amd64/arm64, and installs Gordon to `~/.local/bin` without `sudo`. If that directory is absent from `PATH`, an interactive install can offer to add an idempotent marker block to Fish (`~/.config/fish/config.fish`), Bash (`~/.bashrc`), or Zsh (`~/.zshrc`). Restart the shell after accepting, or follow the printed command to update the current shell.
33+
34+For unattended installs, control PATH configuration explicitly:
35+
36+```bash
37+# Update supported shell configuration without prompting
38+curl -fsSL https://bnema.dev/gordon/install | GORDON_UPDATE_PATH=1 sh
39+
40+# Never modify shell configuration
41+curl -fsSL https://bnema.dev/gordon/install | GORDON_UPDATE_PATH=0 sh
42+
43+# Use another user-local directory
44+curl -fsSL https://bnema.dev/gordon/install | GORDON_INSTALL_DIR="$HOME/bin" GORDON_UPDATE_PATH=1 sh
45+
46+# Explicit global installation (may request sudo)
47+curl -fsSL https://bnema.dev/gordon/install | GORDON_INSTALL_DIR=/usr/local/bin GORDON_UPDATE_PATH=0 sh
48+```
49+
50+`GORDON_INSTALL_DIR` accepts arbitrary safe absolute destinations, including `"$HOME/bin"`, `"$HOME/.local/bin"`, and `/usr/local/bin`. PATH detection and shell configuration use that effective directory. Relative paths, PATH separators, and control characters are rejected. `GORDON_UPDATE_PATH` accepts only `0` or `1`. Do not run the default installer through `sudo`: it refuses to infer a user home and install silently under `/root`.
3351
3452 ### Manual Installation
3553
@@ -136,6 +154,16 @@ insecure = true
136154 EOF
137155 ```
138156
157+Podman assigns loopback backend ports from the host's ephemeral port range when Gordon publishes managed service ports. If an nftables output policy restricts loopback access, allow the Gordon daemon's primary UID—not its subordinate container UIDs—to connect to that range for the protocols used by managed services:
158+
159+```nftables
160+# Confirm the range with: sysctl net.ipv4.ip_local_port_range
161+meta skuid <gordon-uid> ip daddr 127.0.0.1 tcp dport 32768-60999 accept
162+meta skuid <gordon-uid> ip daddr 127.0.0.1 udp dport 32768-60999 accept
163+```
164+
165+Place these rules before any broader loopback rejection. Keep public entrypoint and administrative-port policy separate; these rules only let Gordon reach rootless Podman backends on the local host.
166+
139167 ## Firewall Configuration
140168
141169 Gordon needs ports accessible for the registry and the public edge entrypoint.
@@ -195,11 +223,10 @@ gordon_domain = "gordon.yourdomain.com"
195223 [entrypoints.edge]
196224 address = ":443"
197225 protocol = "smart_tcp"
198-
199-[routes]
200-"app.yourdomain.com" = "myapp:latest"
201226 ```
202227
228+Application workloads live in standalone app files, not in `gordon.toml` (see [Getting Started](./getting-started.md#8-deploy-your-first-app)).
229+
203230 See [Configuration Reference](./config/index.md) for all options.
204231
205232 ## Systemd Service
@@ -310,17 +337,20 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
310337
311338 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.
312339
313-## Installing a Specific Version or Pre-Release
340+## Choosing an Install Channel
314341
315342 ```bash
316-# Install an exact version
317-curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash
343+# Install an exact release
344+curl -fsSL https://bnema.dev/gordon/install | GORDON_VERSION=v2.30.1 sh
345+
346+# Install the latest pre-release
347+curl -fsSL https://bnema.dev/gordon/install | GORDON_PRERELEASE=1 sh
318348
319-# Install the latest pre-release instead of the latest stable release
320-curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
349+# Build the current next branch commit from source
350+curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh
321351 ```
322352
323-By default, the installer resolves `latest` to the newest stable release and verifies the downloaded checksum before installing.
353+Stable, exact-version, and pre-release installs download release binaries and verify their published checksums. The `next` channel is an **unverified development source build**: it resolves the branch through the GitHub API, pins the resulting commit SHA, downloads that exact source snapshot, and builds it locally for the detected platform. It requires the Go version declared by that commit's `go.mod`, is not covered by release checksums, and may be unstable. Do not combine `GORDON_CHANNEL=next` with `GORDON_VERSION` or `GORDON_PRERELEASE`.
324354
325355 ## Verify Installation
326356
@@ -347,7 +377,6 @@ Gordon stores data in the following locations:
347377 | `~/.config/gordon/gordon.toml` | Configuration file |
348378 | `~/.gordon/` | Default data directory |
349379 | `~/.gordon/registry/` | Container images |
350-| `~/.gordon/env/` | Environment files |
351380 | `~/.gordon/logs/` | Application logs |
352381 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |
353382
A docs/migrate-to-v3.md +146 -0
@@ -0,0 +1,146 @@
1+# Migrate to Gordon v3
2+
3+Gordon v3 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 v3 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 v3 no longer accepts from `gordon.toml`:
19+
20+- `[routes]`;
21+- `[attachments]`;
22+- `[network_groups]`;
23+- app-workload entries formerly declared as `[[services]]`, and `[service_routes]` (installation-level `[[services]]` used for standalone L4 workloads stays valid);
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. v3 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+[services.web]
44+image = "registry.example.com/example-app:1.2.3"
45+
46+[services.web.secrets]
47+DATABASE_URL = "database-url"
48+
49+[[services.web.http]]
50+host = "app.example.com"
51+port = 8080
52+```
53+
54+The manifest contains secret **names**, never secret values.
55+
56+Manifests written for an early v3 alpha used `[[service]]` with a `name` field; that shape is rejected, not converted. Rewrite each service as a keyed `[services.<name>]` table with `[services.<name>.*]` children — see [Migrating a v3 alpha app manifest](./upgrading.md#migrating-a-v3-alpha-app-manifest).
57+
58+## 2. Migrate domain secrets
59+
60+Gordon does not convert domain-scoped secrets into app secrets, and v3 has no command to list them. With the `pass` backend, inventory the old key names with `pass ls gordon/env`, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values:
61+
62+```bash
63+gordon apps apply --file ./example-app.toml --remote production
64+printf 'DATABASE_URL=%s\n' "$(pass show gordon/env/app_example_com/DATABASE_URL)" \
65+ | gordon apps secrets set example-app --service web --stdin --remote production
66+```
67+
68+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.
69+
70+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.
71+
72+## 3. Transfer existing volume data
73+
74+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.
75+
76+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.
77+
78+## 4. Apply and deploy apps
79+
80+For every manifest:
81+
82+```bash
83+gordon apps apply --file ./example-app.toml --remote production
84+gordon apps diff example-app --remote production
85+gordon apps deploy example-app --remote production
86+gordon apps status example-app --json --remote production
87+```
88+
89+Push only uploads OCI content in v3; it does not deploy. Apply and deploy explicitly.
90+
91+## 5. Replace tokens and automation
92+
93+Replace removed route scopes with app scopes:
94+
95+- `admin:apps:read` for list, show, diff, and status;
96+- `admin:apps:write` for apply, deploy, lifecycle, and app secrets.
97+
98+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.
99+
100+To roll back an application release in v3, apply a manifest containing the previous image tag and deploy it.
101+
102+## 6. Validate before opening traffic
103+
104+Verify all of the following:
105+
106+- each app is converged on the expected pinned digest;
107+- HTTP, TCP, and UDP entrypoints work as applicable;
108+- app secrets survive a restart and no value appears in output or logs;
109+- named volumes contain the expected data after deploy and restart;
110+- stopped intent survives a daemon restart;
111+- retained and unknown resources remain untouched;
112+- `gordon images prune --dry-run` reports protected app content correctly before any real prune;
113+- the previous binary and matching data backup remain available.
114+
115+Run destructive prune commands only after their dry-run candidate list matches the expected ownership and retention policy.
116+
117+## Security guarantees to rely on after the upgrade
118+
119+- **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.
120+- **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.
121+- **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.
122+- **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.
123+- **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.
124+- **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.
125+- **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.
126+
127+## Rollback
128+
129+A binary downgrade against v3 app state is unsupported. To roll back:
130+
131+1. stop the v3 daemon;
132+2. restore the previous signed binary;
133+3. restore its matching configuration and state backup as one set;
134+4. restore secret-store entries if any were removed;
135+5. restart with automatic pruning disabled;
136+6. verify workloads and data before reopening traffic.
137+
138+Do not attempt rollback by adopting unknown containers or volumes into v3 state.
139+
140+## Related
141+
142+- [Upgrading Gordon](./upgrading.md)
143+- [App Manifest](./config/apps.md)
144+- [Apps CLI](./cli/apps.md)
145+- [Secrets](./config/secrets.md)
146+- [Authentication](./config/auth.md)
M docs/reference/docker-labels.md +22 -150
@@ -4,122 +4,57 @@ Labels used by Gordon for container and image metadata.
44
55 ## Container Labels
66
7-Gordon adds these labels to managed containers:
7+Gordon stamps these ownership labels on every container it creates:
88
99 | Label | Value | Description |
1010 |-------|-------|-------------|
1111 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |
12-| `gordon.domain` | Domain name | Domain this container serves |
13-| `gordon.image` | Image:tag | Original image from configuration |
14-| `gordon.route` | Domain name | Route this container handles |
12+| `gordon.app` | App name | App this container serves |
13+| `gordon.app.service` | Service name | Service this container runs |
14+| `gordon.app.revision` | Revision | Active revision that created it |
1515 | `gordon.created` | Timestamp | When Gordon created the container |
1616
17-### Propagated Image Labels
18-
19-When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing):
20-
21-| Label | Example | Description |
22-|-------|---------|-------------|
23-| `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to |
24-| `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing |
25-
26-These labels only appear on containers whose source image defined them.
17+Queries by logical identity use labels, never name parsing. Resources without these labels (old or foreign containers) are preserved, never adopted or deleted.
2718
28-### Attachment Labels
19+### Legacy Labels
2920
30-Additional labels for attachment containers:
21+Containers created before v3 may carry these labels. They are read-only provenance hints for prune guards — Gordon never infers app state from them:
3122
3223 | Label | Value | Description |
3324 |-------|-------|-------------|
34-| `gordon.attachment` | `"true"` | Container is an attachment service |
35-| `gordon.attached-to` | Domain/group | Route or network group this serves |
36-
37-### Backup Labels
25+| `gordon.domain` | Domain name | Pre-v3 domain this container served |
26+| `gordon.image` | Image:tag | Original image from configuration |
27+| `gordon.route` | Domain name | Pre-v3 route this container handled |
3828
39-Labels used by the backup subsystem:
29+### Backup Metadata
4030
41-| Label | Value | Description |
42-|-------|-------|-------------|
43-| `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container |
44-| `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override |
45-| `gordon.backup.version` | e.g. `"17"` | Explicit database version override |
46-| `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint |
47-| `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers |
31+Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[services.<name>.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars.
4832
4933 ## Image Labels
5034
51-Labels you can set in your Dockerfile:
52-
53-| Label | Example | Description |
54-|-------|---------|-------------|
55-| `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
56-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
57-| `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
58-| `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
59-
60-### Health Check Label
61-
62-When `gordon.health` is set, Gordon performs HTTP GET requests to the specified
63-path during deployment and waits for a 2xx or 3xx response before routing traffic
64-to the new container:
65-
66-```dockerfile
67-FROM node:20-alpine
68-LABEL gordon.health="/api/health"
69-EXPOSE 3000
70-CMD ["node", "server.js"]
71-```
72-
73-Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful
74-response or the `deploy.http_probe_timeout` is reached.
75-
76-### Proxy Port Label
77-
78-When an image exposes multiple ports, Gordon needs to know which one serves HTTP:
79-
80-```dockerfile
81-# Gitea exposes SSH (22) and HTTP (3000)
82-FROM gitea/gitea:latest
83-LABEL gordon.proxy.port=3000
84-EXPOSE 22
85-EXPOSE 3000
86-```
87-
88-Without this label, Gordon uses the first exposed port.
89-
90-**When to use:**
91-- Image exposes multiple ports
92-- First exposed port isn't the HTTP service
93-- You want explicit control over routing
35+No image-label inference exists in v3: 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 (`[services.<name>.readiness]`, `[[services.<name>.http]]`), not from image labels.
9436
9537 ## Container Naming
9638
97-Gordon names containers:
98-
99-| Pattern | Example |
100-|---------|---------|
101-| `gordon-{domain}` | `gordon-app-mydomain-com` |
102-| `gordon-{domain}-new` | `gordon-app-mydomain-com-new` (during updates) |
103-| `gordon-{domain}-{service}` | `gordon-app-mydomain-com-postgres` (attachments) |
39+Gordon names containers `gordon-<app>--<service>--<instance>` where `<instance>` is the creating operation ID. Retiring containers keep their instance names until removal.
10440
10541 ## Inspecting Labels
10642
10743 View labels on a container:
10844
10945 ```bash
110-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | jq
46+docker inspect <container> --format '{{json .Config.Labels}}' | jq
11147 ```
11248
11349 Example output:
11450
11551 ```json
11652 {
117- "gordon.created": "2024-01-15T10:30:00Z",
118- "gordon.domain": "app.mydomain.com",
119- "gordon.image": "myapp:latest",
12053 "gordon.managed": "true",
121- "gordon.proxy.port": "3000",
122- "gordon.route": "app.mydomain.com"
54+ "gordon.app": "blog",
55+ "gordon.app.service": "web",
56+ "gordon.app.revision": "rev-3",
57+ "gordon.created": "2024-01-15T10:30:00Z"
12358 }
12459 ```
12560
@@ -131,75 +66,12 @@ Find Gordon-managed containers:
13166 # All Gordon containers
13267 docker ps -f "label=gordon.managed=true"
13368
134-# Containers for specific domain
135-docker ps -f "label=gordon.domain=app.mydomain.com"
136-
137-# Attachment containers
138-docker ps -f "label=gordon.attachment=true"
139-
140-# Attachments for specific route
141-docker ps -f "label=gordon.attached-to=app.mydomain.com"
142-
143-# Backup sidecars
144-docker ps -f "label=gordon.backup.sidecar=true"
145-```
146-
147-## Examples
148-
149-### Standard Application Container
150-
151-```bash
152-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}'
153-```
154-
155-```json
156-{
157- "gordon.managed": "true",
158- "gordon.domain": "app.mydomain.com",
159- "gordon.image": "myapp:v2.1.0",
160- "gordon.route": "app.mydomain.com",
161- "gordon.created": "2024-01-15T10:30:00Z"
162-}
163-```
164-
165-### Attachment Container
166-
167-```bash
168-docker inspect gordon-app-mydomain-com-postgres --format '{{json .Config.Labels}}'
69+# Containers for a specific app
70+docker ps -f "label=gordon.app=blog"
16971 ```
17072
171-```json
172-{
173- "gordon.managed": "true",
174- "gordon.attachment": "true",
175- "gordon.attached-to": "app.mydomain.com",
176- "gordon.created": "2024-01-15T10:29:00Z"
177-}
178-```
179-
180-### Multi-Port Dockerfile
181-
182-```dockerfile
183-FROM node:18
184-LABEL gordon.proxy.port=3000
185-
186-WORKDIR /app
187-COPY . .
188-
189-# HTTP server on 3000
190-EXPOSE 3000
191-# Metrics on 9090
192-EXPOSE 9090
193-# WebSocket on 8080
194-EXPOSE 8080
195-
196-CMD ["npm", "start"]
197-```
198-
199-Gordon routes HTTP to port 3000.
200-
20173 ## Related
20274
20375 - [Configuration Overview](../config/index.md)
204-- [Attachments](../config/attachments.md)
76+- [App Manifest](../config/apps.md)
20577 - [Concepts](../concepts.md)
D docs/reference/env-variables.md +0 -222
@@ -1,222 +0,0 @@
1-# Environment Variables Reference
2-
3-How Gordon handles environment variables for containers.
4-
5-## Variable Sources
6-
7-Environment variables are loaded from multiple sources and merged:
8-
9-| Source | Priority | Description |
10-|--------|----------|-------------|
11-| Dockerfile `ENV` | Lowest | Default values from image |
12-| `.env` file | Highest | Per-route overrides |
13-
14-Higher priority sources override lower priority values.
15-
16-## Environment File Location
17-
18-Files are stored in the env directory (default: `~/.gordon/env/`):
19-
20-```
21-~/.gordon/env/
22-├── app_mydomain_com.env
23-├── api_mydomain_com.env
24-└── admin_mydomain_com.env
25-```
26-
27-**Naming:** Replace dots with underscores, add `.env` suffix.
28-
29-| Domain | File Name |
30-|--------|-----------|
31-| `app.mydomain.com` | `app_mydomain_com.env` |
32-| `api.company.io` | `api_company_io.env` |
33-| `staging.app.dev` | `staging_app_dev.env` |
34-
35-## File Format
36-
37-Standard `.env` format:
38-
39-```bash
40-# Comments start with #
41-KEY=value
42-ANOTHER_KEY=another value
43-
44-# Quotes are optional but recommended for values with spaces
45-MESSAGE="Hello World"
46-
47-# No spaces around =
48-DATABASE_URL=postgresql://localhost:5432/mydb
49-```
50-
51-## Secret Provider Syntax
52-
53-Reference secrets from configured backends:
54-
55-### Pass Provider
56-
57-```bash
58-# Syntax: ${pass:<path>}
59-DATABASE_PASSWORD=${pass:myapp/database/password}
60-API_KEY=${pass:company/api-key}
61-JWT_SECRET=${pass:production/jwt-secret}
62-```
63-
64-### SOPS Provider
65-
66-```bash
67-# Syntax: ${sops:<file>:<key.path>}
68-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
69-API_SECRET=${sops:production.yaml:api.secret.key}
70-STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
71-```
72-
73-## Variable Expansion
74-
75-### From Secrets
76-
77-```bash
78-# Password from pass
79-DB_PASSWORD=${pass:myapp/db-password}
80-
81-# Then use in connection string
82-DATABASE_URL=postgresql://user:${DB_PASSWORD}@db:5432/mydb
83-```
84-
85-### Static Values
86-
87-```bash
88-# Simple values
89-NODE_ENV=production
90-PORT=3000
91-
92-# Values with special characters (use quotes)
93-MESSAGE="Hello, World!"
94-JSON_CONFIG='{"key": "value"}'
95-```
96-
97-## Dockerfile ENV
98-
99-Variables from Dockerfile serve as defaults:
100-
101-```dockerfile
102-FROM node:18
103-ENV NODE_ENV=development
104-ENV PORT=3000
105-ENV LOG_LEVEL=info
106-```
107-
108-Override in `.env` file:
109-
110-```bash
111-# Override NODE_ENV, keep PORT and LOG_LEVEL defaults
112-NODE_ENV=production
113-```
114-
115-Result:
116-- `NODE_ENV=production` (from .env)
117-- `PORT=3000` (from Dockerfile)
118-- `LOG_LEVEL=info` (from Dockerfile)
119-
120-## Common Variables
121-
122-### Node.js
123-
124-```bash
125-NODE_ENV=production
126-PORT=3000
127-LOG_LEVEL=warn
128-```
129-
130-### Python
131-
132-```bash
133-FLASK_ENV=production
134-DJANGO_SETTINGS_MODULE=myapp.settings.production
135-PYTHONUNBUFFERED=1
136-```
137-
138-### Database Connections
139-
140-```bash
141-# PostgreSQL
142-DATABASE_URL=postgresql://user:pass@postgres:5432/mydb
143-
144-# MySQL
145-DATABASE_URL=mysql://user:pass@mysql:3306/mydb
146-
147-# Redis
148-REDIS_URL=redis://redis:6379/0
149-```
150-
151-### External Services
152-
153-```bash
154-# AWS
155-AWS_ACCESS_KEY_ID=${pass:aws/access-key}
156-AWS_SECRET_ACCESS_KEY=${pass:aws/secret-key}
157-AWS_REGION=us-east-1
158-
159-# Stripe
160-STRIPE_SECRET_KEY=${pass:stripe/secret-key}
161-STRIPE_PUBLISHABLE_KEY=pk_live_...
162-
163-# SendGrid
164-SENDGRID_API_KEY=${pass:sendgrid/api-key}
165-```
166-
167-## Examples
168-
169-### Minimal
170-
171-```bash
172-NODE_ENV=production
173-PORT=3000
174-```
175-
176-### With Database
177-
178-```bash
179-NODE_ENV=production
180-PORT=3000
181-DATABASE_URL=postgresql://postgres:5432/myapp
182-DATABASE_PASSWORD=${pass:myapp/db-password}
183-```
184-
185-### Full Production
186-
187-```bash
188-# Application
189-NODE_ENV=production
190-PORT=3000
191-LOG_LEVEL=warn
192-
193-# Database
194-DATABASE_URL=postgresql://postgres:5432/production
195-DATABASE_PASSWORD=${pass:company/db-password}
196-
197-# Cache
198-REDIS_URL=redis://redis:6379/0
199-
200-# External APIs
201-STRIPE_SECRET_KEY=${pass:company/stripe-secret}
202-SENDGRID_API_KEY=${pass:company/sendgrid-key}
203-
204-# Auth
205-JWT_SECRET=${pass:company/jwt-secret}
206-SESSION_SECRET=${pass:company/session-secret}
207-
208-# Feature flags
209-ENABLE_ANALYTICS=true
210-MAINTENANCE_MODE=false
211-```
212-
213-## Viewing Container Environment
214-
215-```bash
216-docker inspect gordon-app-mydomain-com --format '{{json .Config.Env}}' | jq
217-```
218-
219-## Related
220-
221-- [Environment Configuration](../config/env.md)
222-- [Secrets Configuration](../config/secrets.md)
M docs/reference/index.md +0 -1
@@ -5,7 +5,6 @@ Technical reference documentation for Gordon.
55 ## Contents
66
77 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
8-- [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
98 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
109 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
1110
M docs/reference/troubleshooting.md +27 -46
@@ -91,7 +91,7 @@ No restart needed — Podman reads this on each pull/push.
9191 1. Per-command flag:
9292
9393 ```bash
94- gordon push myapp:latest --insecure
94+ gordon images push myapp:latest --insecure
9595 ```
9696
9797 2. Environment variable:
@@ -118,17 +118,13 @@ No restart needed — Podman reads this on each pull/push.
118118
119119 ### "unknown: image not found"
120120
121-**Cause:** Image was pushed but no route configured.
121+**Cause:** The app manifest references an image that is unavailable to the runtime.
122122
123-**Solution:** Add route to config:
124-```toml
125-[routes]
126-"app.mydomain.com" = "myapp:latest"
127-```
123+**Solution:** Push the image to the configured registry, verify the service's `image` reference in the app manifest, then apply and deploy the accepted revision:
128124
129-Then reload:
130125 ```bash
131-gordon reload
126+gordon apps apply --file app.toml
127+gordon apps deploy app
132128 ```
133129
134130 ## Deployment Issues
@@ -161,14 +157,14 @@ gordon reload
161157
162158 **Solutions:**
163159
164-1. Check container logs:
160+1. Check workload logs:
165161 ```bash
166- docker logs gordon-app-mydomain-com
162+ gordon apps logs blog --service web
167163 ```
168164
169165 2. Check Gordon logs:
170166 ```bash
171- gordon logs -f
167+ gordon daemon logs -f
172168 ```
173169
174170 3. Run container manually to debug:
@@ -178,28 +174,23 @@ gordon reload
178174
179175 ### Environment variables not loaded
180176
181-**Cause:** Env file not found or wrong format.
177+**Cause:** The variable is not declared in the app file, or its secret value was never set.
182178
183179 **Solutions:**
184180
185-1. Check file exists with correct name:
181+1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it:
186182 ```bash
187- ls ~/.gordon/env/
188- # Should show: app_mydomain_com.env (dots → underscores)
183+ gordon apps apply --file ./blog.toml
189184 ```
190185
191-2. Check file permissions:
186+2. Check which secret values are set (names only):
192187 ```bash
193- chmod 600 ~/.gordon/env/app_mydomain_com.env
188+ gordon apps secrets list blog
194189 ```
195190
196-3. Check file format (no spaces around `=`):
191+3. Deploy or restart: values apply on the next deploy/restart, never to running containers.
197192 ```bash
198- # Correct
199- KEY=value
200-
201- # Wrong
202- KEY = value
193+ gordon apps restart blog
203194 ```
204195
205196 ### Secrets not resolved
@@ -234,18 +225,14 @@ gordon reload
234225
235226 **Solutions:**
236227
237-1. Check attachments are configured:
238- ```toml
239- [attachments]
240- "app.mydomain.com" = ["postgres:latest"]
241- ```
228+1. Check that both services declare the same shared network in the app manifest.
242229
243-2. Check containers are in same network:
230+2. Check containers are attached to that network:
244231 ```bash
245- docker network inspect gordon-app-mydomain-com
232+ docker network inspect NETWORK_NAME
246233 ```
247234
248-3. Use correct hostname (image name before colon):
235+3. Use the service name as the internal hostname:
249236 ```javascript
250237 // Correct
251238 connect("postgresql://postgres:5432/mydb")
@@ -326,7 +313,7 @@ gordon reload
326313
327314 **Solution:** Manual reload:
328315 ```bash
329-gordon reload
316+gordon daemon reload
330317 ```
331318
332319 ### Stale `targets.toml` in config directory
@@ -369,15 +356,9 @@ enabled = true
369356 path = "~/.gordon/logs/gordon.log"
370357 ```
371358
372-### Container logs missing
373-
374-**Cause:** Container log collection disabled.
359+### Workload logs unavailable
375360
376-**Solution:**
377-```toml
378-[logging.container_logs]
379-enabled = true # default: true
380-```
361+`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.
381362
382363 ## Diagnostic Commands
383364
@@ -386,7 +367,7 @@ enabled = true # default: true
386367 systemctl --user status gordon
387368
388369 # View Gordon logs
389-gordon logs -f
370+gordon daemon logs -f
390371 journalctl --user -u gordon -f
391372
392373 # List containers
@@ -398,11 +379,11 @@ docker network ls | grep gordon
398379 # List volumes
399380 docker volume ls | grep gordon
400381
401-# Check container logs
402-docker logs gordon-app-mydomain-com
382+# Check workload logs through Gordon
383+gordon apps logs blog --service web
403384
404-# Inspect container
405-docker inspect gordon-app-mydomain-com
385+# Inspect runtime containers when diagnosing locally
386+docker ps -f "label=gordon.app=blog"
406387
407388 # Check connectivity
408389 curl -v http://localhost:5000/v2/
M docs/upgrading.md +67 -29
@@ -2,11 +2,69 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
5+## v3.0.0: Declarative Apps (breaking)
6+
7+Gordon v3 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 v3](./migrate-to-v3.md) for the complete cutover procedure, including explicit migration from domain-scoped secrets to app- and service-scoped secrets.
10+
11+### Removed
12+
13+- App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration.
14+- Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md).
15+- 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`.
16+- Domain secrets: `gordon secrets`, `/admin/secrets` (now `410 Gone`), the `admin:secrets:*` scopes, the `[env]` config section, and startup `.env` import into pass. Use `gordon apps secrets`. Existing `gordon/env/...` pass entries are left untouched.
17+- CLI layout: `gordon push` is now `gordon images push`. `status`, `logs`, `reload`, `config`, `tls status`, `traffic status`, and `networks list` moved under `gordon daemon` (`gordon daemon status`, `gordon daemon tls`, `gordon daemon traffic`, `gordon daemon networks`, …). There are no aliases.
18+- Scopes `admin:routes:*` and `admin:secrets:*` 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"`.
19+- No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.
20+
21+### Migrating a v3 alpha app manifest
22+
23+Early v3 alpha manifests used the array-of-tables form `[[service]]` with a `name` field and `[service.*]` children. That shape is rejected with a keyed-schema diagnostic, not converted. Rewrite each service as a `[services.<name>]` table.
24+
25+Before (alpha, rejected):
26+
27+```toml
28+name = "blog"
29+
30+[[service]]
31+name = "web"
32+image = "registry.example.com/blog/web:1.2.3"
33+
34+[[service.http]]
35+host = "blog.example.com"
36+port = 8080
37+```
38+
39+After (current keyed schema):
40+
41+```toml
42+name = "blog"
43+
44+[services.web]
45+image = "registry.example.com/blog/web:1.2.3"
46+
47+[[services.web.http]]
48+host = "blog.example.com"
49+port = 8080
50+```
51+
52+Names containing dots must be quoted: `[services."web.api"]` and `[[services."web.api".http]]`. See [App Manifest](./config/apps.md).
53+
54+### Manual migration
55+
56+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.
57+2. Delete the removed keys, including `[env]`, from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay).
58+3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations.
59+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 [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets).
60+5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.
61+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.
62+
563 ## Route-Domain Validation
664
765 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.
866
9-## Next major: Unified smart TCP entrypoints
67+## v2.31.0: Unified smart TCP entrypoints
1068
1169 ### Breaking: `server.port` / `server.tls_port` no longer define public listeners
1270
@@ -59,26 +117,6 @@ Keep `server.registry_port` for Docker/Podman push and pull traffic; it is separ
59117 - TLS-ALPN-01 is unsupported.
60118 - Normal HTTPS fallback certificate priority is static certificates, then public ACME certificates, then Gordon's internal CA.
61119
62-## v2.30.0 to v2.31.0
63-
64-### Breaking: Public listener migration
65-
66-`server.port` and `server.tls_port` no longer create public listeners. Gordon refuses to start when either legacy key is present without an `[entrypoints]` entry, preventing routes from silently becoming unavailable.
67-
68-Replace the legacy listener settings with a route-capable entrypoint:
69-
70-```toml
71-[server]
72-registry_port = 5000
73-gordon_domain = "gordon.example.com"
74-
75-[entrypoints.edge]
76-address = ":443"
77-protocol = "smart_tcp"
78-```
79-
80-Keep `server.registry_port` for Docker and Podman registry traffic. For public TLS, configure `[tls.acme]` or static certificates; do not use `server.tls_port`.
81-
82120 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`
83121
84122 The internal CA's HTTP onboarding gate rejects non-localhost HTTP requests by default. If Gordon sits behind Cloudflare or another reverse proxy, add the proxy's edge IPs to `proxy_allowed_ips`:
@@ -114,7 +152,7 @@ registry_domain = "gordon.example.com"
114152 gordon_domain = "gordon.example.com"
115153 ```
116154
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`.
155+If you do not migrate, `gordon daemon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.
118156
119157 ### Staged Registry Host Rename
120158
@@ -214,7 +252,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
214252 - `gordon auth show-token` prints the stored token for a remote
215253 - `gordon auth logout` removes the stored token locally
216254 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
217-- Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
255+- Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations
218256
219257 **Migration steps:**
220258
@@ -262,7 +300,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
262300 5. **Upgrade the binary** and restart:
263301
264302 ```bash
265- curl -fsSL https://gordon.bnema.dev/install | bash
303+ curl -fsSL https://bnema.dev/gordon/install | bash
266304 systemctl --user restart gordon
267305 ```
268306
@@ -294,8 +332,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi
294332 # Read-only monitoring
295333 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
296334
297-# CI deploy with route read + config write
298-gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0
335+# CI deploy with app read + write
336+gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0
299337 ```
300338
301339 See [Token Scopes](./config/auth.md#token-scopes) for the full list.
@@ -433,7 +471,7 @@ If using pass or sops, update your secret paths:
433471 3. **Test in staging** if possible
434472 4. **Upgrade the binary**:
435473 ```bash
436- curl -fsSL https://gordon.bnema.dev/install | bash
474+ curl -fsSL https://bnema.dev/gordon/install | bash
437475 ```
438476 5. **Restart Gordon**:
439477 ```bash
@@ -441,10 +479,10 @@ If using pass or sops, update your secret paths:
441479 ```
442480 6. **Check logs** for any errors:
443481 ```bash
444- gordon logs
482+ gordon daemon logs
445483 ```
446484
447485 ## Getting Help
448486
449487 - [GitHub Issues](https://github.com/bnema/gordon/issues)
450-- [Documentation](https://gordon.bnema.dev/docs)
488+- [Documentation](https://bnema.dev/gordon/docs)
M go.mod +42 -43
@@ -3,10 +3,10 @@ module github.com/bnema/gordon
33 go 1.27
44
55 require (
6- github.com/aws/aws-sdk-go-v2 v1.45.1
7- github.com/aws/aws-sdk-go-v2/config v1.33.1
8- github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.1
9- github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
6+ github.com/aws/aws-sdk-go-v2 v1.47.1
7+ github.com/aws/aws-sdk-go-v2/config v1.33.6
8+ github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.10
9+ github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4
1010 github.com/bnema/zerowrap v1.4.1
1111 github.com/charmbracelet/bubbles v1.0.0
1212 github.com/charmbracelet/bubbletea v1.3.10
@@ -17,12 +17,12 @@ require (
1717 github.com/fsnotify/fsnotify v1.10.1 // direct
1818 github.com/go-acme/lego/v4 v4.35.2
1919 github.com/golang-jwt/jwt/v5 v5.3.1
20- github.com/google/go-containerregistry v0.22.0
20+ github.com/google/go-containerregistry v0.22.1
2121 github.com/google/uuid v1.6.0
2222 github.com/mattn/go-isatty v0.0.24
23- github.com/mattn/go-runewidth v0.0.28
24- github.com/moby/moby/api v1.55.0
25- github.com/moby/moby/client v0.5.1
23+ github.com/mattn/go-runewidth v0.0.30
24+ github.com/moby/moby/api v1.56.0
25+ github.com/moby/moby/client v0.6.0
2626 github.com/muesli/termenv v0.16.0
2727 github.com/pelletier/go-toml/v2 v2.4.3
2828 github.com/rivo/uniseg v0.4.7
@@ -32,21 +32,22 @@ require (
3232 github.com/spf13/cobra v1.10.2
3333 github.com/spf13/viper v1.21.0
3434 github.com/stretchr/testify v1.12.1
35+ go.etcd.io/bbolt v1.5.0
3536 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
3637 go.opentelemetry.io/otel v1.46.0
3738 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0
3839 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0
3940 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
41+ go.opentelemetry.io/otel/log v0.22.0
4042 go.opentelemetry.io/otel/metric v1.46.0
4143 go.opentelemetry.io/otel/sdk v1.46.0
4244 go.opentelemetry.io/otel/sdk/log v0.22.0
4345 go.opentelemetry.io/otel/sdk/metric v1.46.0
4446 go.opentelemetry.io/otel/trace v1.46.0
4547 go.uber.org/mock v0.6.0 // direct
46- golang.org/x/mod v0.40.0
47- golang.org/x/sync v0.22.0
48- golang.org/x/sys v0.47.0
49- golang.org/x/time v0.15.0
48+ golang.org/x/sync v0.23.0
49+ golang.org/x/sys v0.48.0
50+ golang.org/x/time v0.16.0
5051 gopkg.in/natefinch/lumberjack.v2 v2.2.1
5152 )
5253
@@ -54,71 +55,69 @@ require (
5455 github.com/Microsoft/go-winio v0.6.2 // indirect
5556 github.com/atotto/clipboard v0.1.4 // indirect
5657 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
57- github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect
58- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
59- github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
60- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
61- github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
58+ github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect
59+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
60+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
61+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
62+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
6263 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
63- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
64- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
65- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
66- github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
67- github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
68- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
69- github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
70- github.com/aws/smithy-go v1.28.1 // indirect
64+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
65+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
66+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
67+ github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
69+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
70+ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
71+ github.com/aws/smithy-go v1.28.2 // indirect
7172 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
7273 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
7374 github.com/cespare/xxhash/v2 v2.3.0 // indirect
7475 github.com/charmbracelet/colorprofile v0.4.3 // indirect
75- github.com/charmbracelet/x/ansi v0.11.6 // indirect
76+ github.com/charmbracelet/x/ansi v0.11.8 // indirect
7677 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
7778 github.com/charmbracelet/x/term v0.2.2 // indirect
7879 github.com/clipperhouse/displaywidth v0.11.0 // indirect
7980 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
8081 github.com/containerd/errdefs/pkg v0.3.0 // indirect
8182 github.com/distribution/reference v0.6.0 // indirect
82- github.com/docker/cli v29.7.2+incompatible // indirect
83- github.com/docker/docker-credential-helpers v0.9.5 // indirect
83+ github.com/docker/cli v29.8.1+incompatible // indirect
84+ github.com/docker/docker-credential-helpers v0.9.9 // indirect
8485 github.com/docker/go-connections v0.8.1 // indirect
8586 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
8687 github.com/felixge/httpsnoop v1.1.0 // indirect
87- github.com/go-jose/go-jose/v4 v4.1.4 // indirect
88+ github.com/go-jose/go-jose/v4 v4.1.5 // indirect
8889 github.com/go-logr/logr v1.4.4 // indirect
8990 github.com/go-logr/stdr v1.2.2 // indirect
9091 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
91- github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
92+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
9293 github.com/inconshreveable/mousetrap v1.1.0 // indirect
93- github.com/klauspost/compress v1.19.2 // indirect
94- github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
94+ github.com/klauspost/compress v1.20.1 // indirect
95+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
9596 github.com/mattn/go-colorable v0.1.15 // indirect
9697 github.com/mattn/go-localereader v0.0.1 // indirect
97- github.com/miekg/dns v1.1.72 // indirect
98+ github.com/miekg/dns v1.1.73 // indirect
9899 github.com/moby/docker-image-spec v1.3.1 // indirect
99100 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
100101 github.com/muesli/cancelreader v0.2.2 // indirect
101102 github.com/opencontainers/go-digest v1.0.0 // indirect
102103 github.com/opencontainers/image-spec v1.1.1 // indirect
103104 github.com/sagikazarmark/locafero v0.12.0 // indirect
104- github.com/sirupsen/logrus v1.9.4 // indirect
105+ github.com/sirupsen/logrus v1.10.2 // indirect
105106 github.com/spf13/cast v1.10.0 // indirect
106107 github.com/spf13/pflag v1.0.10 // indirect
107108 github.com/stretchr/objx v0.5.3 // indirect
108109 github.com/subosito/gotenv v1.6.0 // indirect
109- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
110+ github.com/xo/terminfo v1.2.0 // indirect
110111 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
111112 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
112- go.opentelemetry.io/otel/log v0.22.0 // indirect
113113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect
114114 go.yaml.in/yaml/v3 v3.0.5 // indirect
115- golang.org/x/crypto v0.55.0 // indirect
116- golang.org/x/net v0.58.0 // indirect
117- golang.org/x/text v0.41.0 // indirect
118- golang.org/x/tools v0.49.0 // indirect
119- google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
120- google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
121- google.golang.org/grpc v1.83.1 // indirect
115+ golang.org/x/crypto v0.57.0 // indirect
116+ golang.org/x/net v0.59.0 // indirect
117+ golang.org/x/text v0.42.0 // indirect
118+ google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect
119+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect
120+ google.golang.org/grpc v1.84.0 // indirect
122121 google.golang.org/protobuf v1.36.12 // indirect
123122 howett.net/plist v1.0.1 // indirect
124123 )
M go.sum +82 -86
@@ -2,44 +2,44 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
22 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
33 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
44 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
5-github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
6-github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
5+github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
6+github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
77 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
88 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
9-github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
10-github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
11-github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
12-github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
13-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
14-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
15-github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.1 h1:I3mWvASaICc5c8vJ3ftYjroh7LT3jG0q/KtzSu5wW/s=
16-github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.1/go.mod h1:VwSN8piv62OyyxYWtJzj6j7gECyLc0WwMo30L5NQZlE=
17-github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
18-github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
19-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
20-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
21-github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
22-github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
9+github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
10+github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
11+github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
12+github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
13+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
14+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
15+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.10 h1:DaCvPvqeZCMqziWeKPiLCv/tUvpe4/Cq79bjE1ZGtHY=
16+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.10/go.mod h1:eHZtvcgtQ2WQDWcIs/VUuM3zglCEGipTaxfaTmzZxpw=
17+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
18+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
19+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
20+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
21+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
22+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
2323 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
2424 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
25-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
26-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
27-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
28-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
29-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
30-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
31-github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
32-github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
33-github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
34-github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
35-github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
36-github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
37-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
38-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
39-github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
40-github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
41-github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
42-github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
25+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
26+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
27+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
28+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
29+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
30+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
31+github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 h1:n6kO3OlBvnDEksQpvBLbAldjHwGlu8kErvhHJkhlaRY=
32+github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
33+github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
34+github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
35+github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
36+github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
37+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
38+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
39+github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
40+github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
41+github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg=
42+github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
4343 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4444 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
4545 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -58,8 +58,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
5858 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
5959 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
6060 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
61-github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
62-github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
61+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
62+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
6363 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
6464 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
6565 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
@@ -79,10 +79,10 @@ github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7
7979 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
8080 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
8181 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
82-github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
83-github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
84-github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
85-github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
82+github.com/docker/cli v29.8.1+incompatible h1:qYL1bCp6cRw2SB1xmLlIOPyV171dilw9W2Jew38vy9c=
83+github.com/docker/cli v29.8.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
84+github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w=
85+github.com/docker/docker-credential-helpers v0.9.9/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
8686 github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
8787 github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
8888 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -97,8 +97,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
9797 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
9898 github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
9999 github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
100-github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
101-github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
100+github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
101+github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
102102 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
103103 github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
104104 github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -112,39 +112,39 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
112112 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
113113 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
114114 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
115-github.com/google/go-containerregistry v0.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I=
116-github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
115+github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
116+github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
117117 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
118118 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
119-github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
120-github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
119+github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
120+github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
121121 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
122122 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
123123 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
124-github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
125-github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
124+github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
125+github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
126126 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
127127 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
128128 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
129129 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
130-github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
131-github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
130+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
131+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
132132 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
133133 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
134134 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
135135 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
136136 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
137137 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
138-github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
139-github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
140-github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
141-github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
138+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
139+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
140+github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
141+github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
142142 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
143143 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
144-github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
145-github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
146-github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
147-github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
144+github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
145+github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
146+github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
147+github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
148148 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
149149 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
150150 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -166,8 +166,8 @@ github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+
166166 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
167167 github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
168168 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
169-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
170-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
169+github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
170+github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
171171 github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4=
172172 github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A=
173173 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -187,8 +187,10 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
187187 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
188188 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
189189 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
190-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
191-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
190+github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
191+github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
192+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
193+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
192194 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
193195 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
194196 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
@@ -228,33 +230,27 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
228230 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
229231 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
230232 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
231-golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
232-golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
233-golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
234-golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
235-golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
236-golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
237-golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
238-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
239-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
240-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
233+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
234+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
235+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
236+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
237+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
238+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
241239 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
242-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
243-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
244-golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
245-golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
246-golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
247-golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
248-golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
249-golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
240+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
241+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
242+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
243+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
244+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
245+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
250246 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
251247 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
252-google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
253-google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
254-google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
255-google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
256-google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
257-google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
248+google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 h1:GS9OIt/j7c8bvBjYNgnKQysVfmV7e4jM0H8ZK95G4t8=
249+google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459/go.mod h1:PX5/4vemwVoXtwEcRDWwcR1/r0qrosfx3qoVADMwnVE=
250+google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8=
251+google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
252+google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
253+google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
258254 google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
259255 google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
260256 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
M gordon.toml.example +64 -33
@@ -55,21 +55,15 @@ per_ip_rps = 50
5555 burst = 100
5656 trusted_proxies = []
5757
58-[deploy]
59-pull_policy = "if-tag-changed"
60-readiness_mode = "auto"
61-health_timeout = "90s"
62-readiness_delay = "5s"
63-drain_mode = "auto"
64-drain_timeout = "30s"
65-drain_delay = "2s"
66-
6758 [containers]
6859 # "compat" preserves broad image compatibility. "strict" enables a read-only
6960 # root filesystem and narrower Linux capabilities.
7061 security_profile = "compat"
7162
7263 [network_isolation]
64+# Installation network policy for Gordon-managed networks (prefix filter
65+# for `gordon daemon networks`). Per-app isolation is removed: apps declare
66+# `[[network.shared]]` membership in their app file instead.
7367 enabled = true
7468 network_prefix = "gordon"
7569 # Set true to create Docker internal networks without direct external egress.
@@ -81,10 +75,10 @@ prefix = "gordon"
8175 preserve = true
8276
8377 [images]
84-# Explicit external registries must be listed here. Gordon's own registry is
85-# always allowed; localhost/private/link-local registries are always rejected.
78+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed.
79+# Add other registries as exact hostname+port entries; this does not configure credentials.
8680 allowed_registries = []
87-# Require @sha256:<digest> for allowlisted external registries.
81+# Require @sha256:<64 hex chars> for images from every registry, including Gordon.
8882 require_digest = false
8983
9084 [images.prune]
@@ -92,9 +86,6 @@ enabled = false
9286 schedule = "daily"
9387 keep_last = 3
9488
95-[auto_route]
96-enabled = false
97-
9889 [logging]
9990 level = "info"
10091 format = "console"
@@ -106,13 +97,6 @@ enabled = false
10697 # max_backups = 3
10798 # max_age = 28
10899
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-
116100 [logging.access_log]
117101 enabled = false
118102 format = "json"
@@ -124,6 +108,40 @@ max_age = 28
124108 exclude_health_checks = true
125109 syslog_identifier = "gordon-access"
126110
111+# =============================================================================
112+# ADMINISTRATIVE APP BIND MOUNTS
113+# =============================================================================
114+# A named policy is the only way an app manifest may reference a host path.
115+# The manifest declares [[services.<name>.bind]] name = "<mount>"; direct host paths
116+# are always rejected. allowed_apps/allowed_services are exact and non-empty,
117+# and read_only/readonly force read-only: either side wins.
118+# [app_mounts.app-logs]
119+# source = "/srv/gordon/host-logs" # Required; absolute, normalized
120+# read_only = true # Either side forcing read-only wins
121+# allowed_apps = ["metrics-agent"] # Required; exact, non-empty
122+# allowed_services = ["web"] # Required; exact, non-empty
123+# root = "/srv/gordon" # Optional; defaults to the source's parent
124+
125+# =============================================================================
126+# ADMINISTRATIVE APP DEVICES (CDI)
127+# =============================================================================
128+# A named device grant is the only way an app manifest may request host
129+# devices. The manifest declares devices = ["<name>"]; raw /dev paths,
130+# privileged containers, and runtime-specific GPU flags are never used.
131+# Gordon resolves each name to explicit CDI IDs at activation time and
132+# encodes them as one native CDI DeviceRequest. allowed_apps and
133+# allowed_services are exact and non-empty: only that app+service pair
134+# receives the grant. Aggregate "=all" selectors are rejected: name every
135+# device explicitly. Changing a mapping never recreates a running
136+# container; the next deploy serves the new resolution. Revoking a grant
137+# fails subsequent deploys closed while the running service is untouched.
138+# Supported engines: Podman 5.4+ or Docker 28.3+ with native CDI
139+# configured (CDI specs, toolkit, device permissions on the host).
140+# [app_devices.transcode-gpu]
141+# cdi = ["example.com/gpu=GPU-device-uuid"] # Required; explicit, non-empty
142+# allowed_apps = ["video"] # Required; exact, non-empty
143+# allowed_services = ["transcoder"] # Required; exact, non-empty
144+
127145 [telemetry]
128146 enabled = false
129147 # endpoint = "http://localhost:4318"
@@ -133,9 +151,6 @@ metrics = true
133151 logs = true
134152 trace_sample_rate = 1.0
135153
136-[env]
137-# dir = "~/.gordon/env"
138-
139154 [backups.databases]
140155 enabled = false
141156 schedule = "daily"
@@ -168,14 +183,33 @@ prefix = ""
168183 [backups.volumes.retention]
169184 keep = 14
170185
171-[routes]
186+# [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed.
172187 # "app.example.com" = { image = "myapp:latest" }
173188
174189 [external_routes]
175190 # "status.example.com" = "127.0.0.1:9000"
191+#
192+# NOTE: the pre-v3 application keys below were removed by the
193+# declarative-apps cutover. Gordon fails boot/reload closed when any of
194+# them is present (see validateRetiredAppConfig in internal/app/run.go).
195+# Declare application workloads in standalone <app>.toml files instead:
196+# one file per app, applied with `gordon apps apply --file <app>.toml`
197+# and activated with `gordon apps deploy <app>`.
198+#
199+# REMOVED: [routes] -> [[services.<name>.http]] in an app file
200+# REMOVED: [attachments] -> [services.<name>] + volumes in an app file
201+# REMOVED: [network_groups] -> [[network.shared]] in an app file
202+# REMOVED: app-like [[services]] -> one [services.<name>] table per app file
203+# (installation [[services]] below stays valid for L4 workloads)
204+# REMOVED: [service_routes] -> [[services.<name>.http]] in an app file
205+# REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file
206+# REMOVED: [previews] -> staging is an ordinary app file
176207
177208 # Gordon-managed standalone L4 services. Use service:<name>:<port> from
178-# traffic routers to expose published loopback ports.
209+# traffic routers to expose published loopback ports. Standalone services
210+# are installation-level: they live in gordon.toml, not in app files.
211+# (App services are declared with [services.<name>] tables in <app>.toml files and
212+# managed with `gordon apps`; see docs/cli/apps.md.)
179213 # [[services]]
180214 # name = "rust"
181215 # image = "registry.example.com:5000/rust:latest"
@@ -213,9 +247,6 @@ keep = 14
213247 # name = "rust-rcon"
214248 # entrypoint = "rcon"
215249 # service = "service:rust:rcon"
216-
217-[network_groups]
218-# "backend" = ["app.example.com", "api.example.com"]
219-
220-[attachments]
221-# "app.example.com" = ["postgres:18"]
250+#
251+# REMOVED application sections (see note under [external_routes] above):
252+# [routes], [network_groups], [attachments] were deleted with the cutover.
M install.sh +303 -18
@@ -2,12 +2,55 @@
22 set -e
33
44 # Gordon installer script
5-# Usage: curl -fsSL https://gordon.bnema.dev/install | bash
6-# Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
5+# Usage: curl -fsSL https://bnema.dev/gordon/install | sh
6+# Usage with pre-release: curl -fsSL https://bnema.dev/gordon/install | GORDON_PRERELEASE=1 sh
7+# Usage with next source build: curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh
78
89 REPO="bnema/gordon"
9-INSTALL_DIR="/usr/local/bin"
1010 VERSION="${GORDON_VERSION:-latest}"
11+CHANNEL="${GORDON_CHANNEL:-stable}"
12+
13+case "${GORDON_UPDATE_PATH:-}" in
14+ ''|0|1) ;;
15+ *) echo "Error: GORDON_UPDATE_PATH must be 0 or 1"; exit 1 ;;
16+esac
17+
18+if [ -n "${GORDON_INSTALL_DIR+x}" ]; then
19+ INSTALL_DIR=$GORDON_INSTALL_DIR
20+ if [ -z "$INSTALL_DIR" ]; then
21+ echo "Error: GORDON_INSTALL_DIR must not be empty"
22+ exit 1
23+ fi
24+else
25+ if [ -n "${SUDO_USER:-}" ] || [ "$(id -u)" -eq 0 ]; then
26+ echo "Error: Refusing a default user-local install while running as root or through sudo."
27+ echo "Run the installer as the target user, or set GORDON_INSTALL_DIR explicitly for a global install."
28+ exit 1
29+ fi
30+ if [ -z "${HOME:-}" ]; then
31+ echo "Error: HOME is required for the default installation directory"
32+ exit 1
33+ fi
34+ INSTALL_DIR=$HOME/.local/bin
35+fi
36+
37+case "$INSTALL_DIR" in
38+ /*) ;;
39+ *) echo "Error: GORDON_INSTALL_DIR must be an absolute path"; exit 1 ;;
40+esac
41+if [ "$INSTALL_DIR" != "/" ]; then
42+ while [ "${INSTALL_DIR%/}" != "$INSTALL_DIR" ]; do
43+ INSTALL_DIR=${INSTALL_DIR%/}
44+ done
45+fi
46+INSTALL_DIR_WITHOUT_CONTROLS=$(LC_ALL=C printf '%sX' "$INSTALL_DIR" | LC_ALL=C tr -d '\000-\037\177')
47+if [ "$INSTALL_DIR" = "/" ] || [ "$INSTALL_DIR_WITHOUT_CONTROLS" != "${INSTALL_DIR}X" ]; then
48+ echo "Error: GORDON_INSTALL_DIR contains an unsafe control character or destination"
49+ exit 1
50+fi
51+case "$INSTALL_DIR" in
52+ *:*) echo "Error: GORDON_INSTALL_DIR must not contain ':' because it separates PATH entries"; exit 1 ;;
53+esac
1154
1255 echo "Installing Gordon..."
1356
@@ -43,6 +86,259 @@ esac
4386
4487 echo "Detected: ${OS}/${ARCH}"
4588
89+install_binary_atomically() {
90+ binary=$1
91+ destination=$2
92+ destination_dir=${destination%/*}
93+ staging="${destination}.install.$$"
94+
95+ if ! mkdir -p "$destination_dir" 2>/dev/null; then
96+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
97+ echo "Error: Could not create user installation directory ${destination_dir}"
98+ return 1
99+ fi
100+ echo "sudo required to create ${destination_dir}"
101+ sudo mkdir -p "$destination_dir"
102+ fi
103+
104+ if [ -w "$destination_dir" ]; then
105+ trap 'rm -f "$staging"; rm -rf "${TMP_DIR:-}"' EXIT HUP INT TERM
106+ install -m 0755 "$binary" "$staging"
107+ mv -f "$staging" "$destination"
108+ else
109+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
110+ echo "Error: User installation directory is not writable: ${destination_dir}"
111+ return 1
112+ fi
113+ echo "sudo required to install to ${destination_dir}"
114+ sudo install -m 0755 "$binary" "$staging"
115+ if ! sudo mv -f "$staging" "$destination"; then
116+ sudo rm -f "$staging"
117+ return 1
118+ fi
119+ fi
120+}
121+
122+path_contains_install_dir() {
123+ case ":${PATH:-}:" in
124+ *:"$INSTALL_DIR":*) return 0 ;;
125+ *) return 1 ;;
126+ esac
127+}
128+
129+single_quote() {
130+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
131+}
132+
133+print_path_instructions() {
134+ quoted_dir=$(single_quote "$INSTALL_DIR")
135+ echo "Add Gordon to the current shell with:"
136+ case "${SHELL##*/}" in
137+ fish) echo " fish_add_path ${quoted_dir}" ;;
138+ *) echo " export PATH=${quoted_dir}:\$PATH" ;;
139+ esac
140+}
141+
142+update_shell_path() {
143+ shell_name=${SHELL##*/}
144+ marker_start="# >>> Gordon installer PATH >>>"
145+ marker_end="# <<< Gordon installer PATH <<<"
146+
147+ case "$shell_name" in
148+ fish)
149+ config_file=$HOME/.config/fish/config.fish
150+ config_dir=${config_file%/*}
151+ command_line="fish_add_path $(single_quote "$INSTALL_DIR")"
152+ ;;
153+ bash)
154+ config_file=$HOME/.bashrc
155+ config_dir=$HOME
156+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
157+ ;;
158+ zsh)
159+ config_file=$HOME/.zshrc
160+ config_dir=$HOME
161+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
162+ ;;
163+ *)
164+ echo "Could not update PATH automatically: unsupported or missing SHELL (${SHELL:-unset})."
165+ print_path_instructions
166+ return 0
167+ ;;
168+ esac
169+
170+ if ! mkdir -p "$config_dir" 2>/dev/null ||
171+ { [ -e "$config_file" ] && [ ! -w "$config_file" ]; } ||
172+ { [ ! -e "$config_file" ] && [ ! -w "$config_dir" ]; }; then
173+ echo "Could not update PATH automatically: ${config_file} is not writable."
174+ print_path_instructions
175+ return 0
176+ fi
177+
178+ if [ -f "$config_file" ] && grep -F "$marker_start" "$config_file" >/dev/null 2>&1; then
179+ echo "PATH configuration already exists in ${config_file}."
180+ else
181+ {
182+ printf '\n%s\n' "$marker_start"
183+ printf '%s\n' "$command_line"
184+ printf '%s\n' "$marker_end"
185+ } >>"$config_file"
186+ echo "Added ${INSTALL_DIR} to PATH in ${config_file}."
187+ fi
188+ print_path_instructions
189+}
190+
191+post_install() {
192+ echo ""
193+ echo "Gordon installed successfully at ${INSTALL_DIR}/gordon."
194+
195+ if path_contains_install_dir; then
196+ "$INSTALL_DIR/gordon" version
197+ return
198+ fi
199+
200+ case "${GORDON_UPDATE_PATH:-}" in
201+ 1) update_shell_path ;;
202+ 0) print_path_instructions ;;
203+ '')
204+ if [ -r /dev/tty ] && [ -w /dev/tty ]; then
205+ printf 'Add %s to your PATH configuration? [y/N] ' "$INSTALL_DIR" >/dev/tty
206+ if IFS= read -r answer </dev/tty; then
207+ case "$answer" in
208+ y|Y|yes|YES|Yes) update_shell_path ;;
209+ *) print_path_instructions ;;
210+ esac
211+ else
212+ print_path_instructions
213+ fi
214+ else
215+ print_path_instructions
216+ fi
217+ ;;
218+ esac
219+}
220+
221+version_at_least() {
222+ actual=$1
223+ required=$2
224+ actual_major=${actual%%.*}
225+ actual_minor=${actual#*.}
226+ actual_minor=${actual_minor%%.*}
227+ required_major=${required%%.*}
228+ required_minor=${required#*.}
229+ required_minor=${required_minor%%.*}
230+
231+ [ "$actual_major" -gt "$required_major" ] || {
232+ [ "$actual_major" -eq "$required_major" ] && [ "$actual_minor" -ge "$required_minor" ]
233+ }
234+}
235+
236+install_next() {
237+ if [ -n "${GORDON_VERSION:-}" ]; then
238+ echo "Error: GORDON_VERSION cannot be used with GORDON_CHANNEL=next"
239+ exit 1
240+ fi
241+ if [ -n "${GORDON_PRERELEASE:-}" ]; then
242+ echo "Error: GORDON_PRERELEASE cannot be used with GORDON_CHANNEL=next"
243+ exit 1
244+ fi
245+ if ! command -v go >/dev/null 2>&1; then
246+ echo "Error: GORDON_CHANNEL=next requires Go"
247+ exit 1
248+ fi
249+
250+ echo "WARNING: UNVERIFIED DEVELOPMENT BUILD"
251+ echo "The next channel builds source locally from the current next branch commit."
252+ echo "It is not covered by the signed release checksum path and may be unstable."
253+ echo "Resolving the current next commit..."
254+ COMMIT_DATA=$(curl -fsSL -H "Accept: application/vnd.github+json" \
255+ "https://api.github.com/repos/${REPO}/commits/next" 2>/dev/null || echo "")
256+ COMMIT=$(printf '%s\n' "$COMMIT_DATA" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]*\)".*/\1/p' | head -n 1)
257+ case "$COMMIT" in
258+ *[!0-9a-fA-F]*|'') COMMIT="" ;;
259+ esac
260+ if [ "${#COMMIT}" -ne 40 ]; then
261+ echo "Error: Could not resolve an exact next commit SHA from the GitHub API"
262+ exit 1
263+ fi
264+
265+ TMP_DIR=$(mktemp -d)
266+ trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
267+ SOURCE_TARBALL="$TMP_DIR/source.tar.gz"
268+ SOURCE_URL="https://codeload.github.com/${REPO}/tar.gz/${COMMIT}"
269+ echo "Downloading source pinned to ${COMMIT}..."
270+ if ! curl -fsSL "$SOURCE_URL" -o "$SOURCE_TARBALL"; then
271+ echo "Error: Failed to download source for ${COMMIT}"
272+ exit 1
273+ fi
274+
275+ SOURCE_ROOT="gordon-${COMMIT}"
276+ if ! tar -tzf "$SOURCE_TARBALL" >"$TMP_DIR/archive.list"; then
277+ echo "Error: Invalid source archive"
278+ exit 1
279+ fi
280+ if [ ! -s "$TMP_DIR/archive.list" ] ||
281+ grep -Ev "^${SOURCE_ROOT}(/.*)?$" "$TMP_DIR/archive.list" >/dev/null ||
282+ grep -E '(^|/)\.\.?(/|$)' "$TMP_DIR/archive.list" >/dev/null ||
283+ tar -tvzf "$SOURCE_TARBALL" | grep -Ev '^[-d]' >/dev/null; then
284+ echo "Error: Source archive contains an unsafe path or entry type"
285+ exit 1
286+ fi
287+ tar -xzf "$SOURCE_TARBALL" -C "$TMP_DIR"
288+ SOURCE_DIR="$TMP_DIR/$SOURCE_ROOT"
289+ if [ ! -f "$SOURCE_DIR/go.mod" ] || [ ! -f "$SOURCE_DIR/main.go" ]; then
290+ echo "Error: Source archive is missing go.mod or the root command"
291+ exit 1
292+ fi
293+
294+ REQUIRED_GO=$(awk '$1 == "go" { print $2; exit }' "$SOURCE_DIR/go.mod")
295+ case "$REQUIRED_GO" in
296+ [0-9]*.[0-9]*) ;;
297+ *) echo "Error: Source go.mod has no valid Go version"; exit 1 ;;
298+ esac
299+ INSTALLED_GO=$(go env GOVERSION 2>/dev/null || true)
300+ INSTALLED_GO=${INSTALLED_GO#go}
301+ case "$INSTALLED_GO" in
302+ [0-9]*.[0-9]*) ;;
303+ *) echo "Error: Could not determine installed Go version"; exit 1 ;;
304+ esac
305+ if ! version_at_least "$INSTALLED_GO" "$REQUIRED_GO"; then
306+ echo "Error: Source requires Go ${REQUIRED_GO} or newer; found ${INSTALLED_GO}"
307+ exit 1
308+ fi
309+
310+ BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
311+ NEXT_VERSION="next-${COMMIT}"
312+ BINARY="$TMP_DIR/gordon"
313+ echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."
314+ # The source is a pristine tarball of the pinned commit, so it can
315+ # never be a dirty checkout: dirty is reported as false.
316+ if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \
317+ go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X main.dirty=false" \
318+ -o "$BINARY" .); then
319+ echo "Error: Failed to build Gordon from source"
320+ exit 1
321+ fi
322+ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
323+ echo "Error: Build did not produce a regular Gordon binary"
324+ exit 1
325+ fi
326+
327+ echo "Installing to ${INSTALL_DIR}..."
328+ install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
329+ echo "Installed unverified next build ${COMMIT}."
330+}
331+
332+case "$CHANNEL" in
333+ next)
334+ install_next
335+ post_install
336+ exit 0
337+ ;;
338+ stable|'') ;;
339+ *) echo "Error: Unsupported GORDON_CHANNEL: $CHANNEL"; exit 1 ;;
340+esac
341+
46342 # Construct download URL
47343 TARBALL="gordon_${OS}_${ARCH}.tar.gz"
48344
@@ -101,6 +397,8 @@ fi
101397
102398 # Verify checksum
103399 echo "Verifying checksum..."
400+# The sed expression is intentionally literal; shell expansion would corrupt it.
401+# shellcheck disable=SC2016
104402 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105403 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
106404 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
@@ -147,19 +445,6 @@ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
147445 fi
148446
149447 echo "Installing to ${INSTALL_DIR}..."
150-if [ -w "$INSTALL_DIR" ]; then
151- install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
152-else
153- echo "sudo required to install to ${INSTALL_DIR}"
154- sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
155-fi
448+install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
156449
157-# Verify installation
158-if command -v gordon >/dev/null 2>&1; then
159- echo ""
160- echo "Gordon installed successfully!"
161- gordon version
162-else
163- echo ""
164- echo "Installation complete. You may need to add ${INSTALL_DIR} to your PATH."
165-fi
450+post_install
A internal/adapters/dto/admin_apps.go +203 -0
@@ -0,0 +1,203 @@
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 v3 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+ // All confirms an app-wide deploy of a multi-service app.
51+ All bool `json:"all,omitempty"`
52+}
53+
54+// AppServiceResultDTO is one service's terminal deployment result.
55+type AppServiceResultDTO struct {
56+ Result string `json:"result"`
57+ EffectiveRevision string `json:"effective_revision"`
58+ Before string `json:"before,omitempty"`
59+ After string `json:"after,omitempty"`
60+ RestartUnsafe bool `json:"restart_unsafe"`
61+ Error string `json:"error,omitempty"`
62+}
63+
64+// AppStepDTO is one journaled operation step.
65+type AppStepDTO struct {
66+ ID string `json:"id"`
67+ State string `json:"state"`
68+ Detail string `json:"detail,omitempty"`
69+ Error string `json:"error,omitempty"`
70+ Before string `json:"before,omitempty"`
71+ After string `json:"after,omitempty"`
72+ // Diagnostics is redacted failure output, populated only for callers
73+ // holding the logs read scope.
74+ Diagnostics []string `json:"diagnostics,omitempty"`
75+}
76+
77+// AppCleanupWarningDTO records post-publication leftovers.
78+type AppCleanupWarningDTO struct {
79+ Service string `json:"service"`
80+ Leftover string `json:"leftover"`
81+ Detail string `json:"detail"`
82+}
83+
84+// AppEffectiveDTO maps services to effective revisions.
85+type AppEffectiveDTO struct {
86+ Converged bool `json:"converged"`
87+ ConvergedRevision string `json:"converged_revision,omitempty"`
88+ Services map[string]string `json:"services"`
89+}
90+
91+// AppRetainedDTO lists the resources an app owns and would retain on
92+// removal (names and paths, never values).
93+type AppRetainedDTO struct {
94+ Volumes []string `json:"volumes"`
95+ Secrets []string `json:"secrets"`
96+ Images []string `json:"images"`
97+}
98+
99+// AppSecretMetadataDTO reports registration metadata without secret values.
100+type AppSecretMetadataDTO struct {
101+ Service string `json:"service"`
102+ Key string `json:"key"`
103+ Name string `json:"name"`
104+ Source string `json:"source"`
105+ Presence string `json:"presence"`
106+}
107+
108+// AppStatusRunning is the wire status of an operation whose effects are
109+// still executing in the background. A terminal operation reports its
110+// persisted outcome as the status instead.
111+const AppStatusRunning = "running"
112+
113+// AppDeployResponse reports deploy outcome with terminal results.
114+// Effective and Retained are filled from current app state when the app
115+// still exists; they are omitted when it does not.
116+type AppDeployResponse struct {
117+ Op string `json:"op"`
118+ App string `json:"app"`
119+ Revision string `json:"revision"`
120+ Status string `json:"status"`
121+ Outcome string `json:"outcome"`
122+ Services map[string]AppServiceResultDTO `json:"services"`
123+ Steps []AppStepDTO `json:"steps"`
124+ CleanupWarnings []AppCleanupWarningDTO `json:"cleanup_warnings,omitempty"`
125+ Effective *AppEffectiveDTO `json:"effective,omitempty"`
126+ Retained *AppRetainedDTO `json:"retained,omitempty"`
127+}
128+
129+// AppDesiredDTO summarizes desired state.
130+type AppDesiredDTO struct {
131+ Revision string `json:"revision,omitempty"`
132+ Status string `json:"status,omitempty"`
133+ // Pending reports desired state ACTIVE has not reached yet.
134+ Pending bool `json:"pending"`
135+}
136+
137+// AppActiveServiceDTO is one effective service for inspection.
138+type AppActiveServiceDTO struct {
139+ EffectiveRevision string `json:"effective_revision"`
140+ Digest string `json:"digest,omitempty"`
141+ Container string `json:"container,omitempty"`
142+ RestartUnsafe bool `json:"restart_unsafe"`
143+}
144+
145+// AppActiveDTO carries per-service effective state.
146+type AppActiveDTO struct {
147+ Converged bool `json:"converged"`
148+ ConvergedRevision string `json:"converged_revision,omitempty"`
149+ Services map[string]AppActiveServiceDTO `json:"services"`
150+}
151+
152+// AppIntentDTO carries durable stopped/running intent.
153+type AppIntentDTO struct {
154+ Stopped bool `json:"stopped"`
155+}
156+
157+// AppLastOpDTO references the latest operation.
158+type AppLastOpDTO struct {
159+ Op string `json:"op"`
160+ Kind string `json:"kind,omitempty"`
161+ Outcome string `json:"outcome,omitempty"`
162+ StartedAt time.Time `json:"started_at,omitzero"`
163+}
164+
165+// AppShowResponse inspects desired + active + intent + ownership + op ref.
166+type AppShowResponse struct {
167+ App string `json:"app"`
168+ Desired AppDesiredDTO `json:"desired"`
169+ Active AppActiveDTO `json:"active"`
170+ Intent AppIntentDTO `json:"intent"`
171+ Retained AppRetainedDTO `json:"retained"`
172+ LastOp *AppLastOpDTO `json:"last_op,omitempty"`
173+}
174+
175+// AppSummaryDTO is one row of the app list.
176+type AppSummaryDTO struct {
177+ App string `json:"app"`
178+ Desired string `json:"desired,omitempty"`
179+ DesiredStatus string `json:"desired_status,omitempty"`
180+ Active string `json:"active,omitempty"`
181+ Converged bool `json:"converged"`
182+ Pending bool `json:"pending"`
183+ Stopped bool `json:"stopped"`
184+ LastOutcome string `json:"last_outcome,omitempty"`
185+}
186+
187+// AppDiffResponse reports normalized desired-vs-active diff.
188+type AppDiffResponse struct {
189+ App string `json:"app"`
190+ Diff AppDiffSection `json:"diff"`
191+}
192+
193+// AppSecretSetRequest writes secret values (names pre-registered).
194+type AppSecretSetRequest struct {
195+ Service string `json:"service"`
196+ Secrets map[string]string `json:"secrets"`
197+}
198+
199+// AppSecretDeleteRequest removes one secret value.
200+type AppSecretDeleteRequest struct {
201+ Service string `json:"service"`
202+ Key string `json:"key"`
203+}
A internal/adapters/dto/admin_apps_test.go +148 -0
@@ -0,0 +1,148 @@
1+package dto
2+
3+// Frozen-wire tests for the v3 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+ Status: AppStatusRunning,
67+ Outcome: "success",
68+ Services: map[string]AppServiceResultDTO{
69+ "web": {Result: "deployed", EffectiveRevision: "rev-new"},
70+ },
71+ Steps: []AppStepDTO{{ID: "service.web.replace", State: "succeeded"}},
72+ CleanupWarnings: []AppCleanupWarningDTO{
73+ {Service: "web", Leftover: "ctr-old", Detail: "retire failed after publish"},
74+ },
75+ Effective: &AppEffectiveDTO{Converged: true, Services: map[string]string{"web": "rev-new"}},
76+ Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
77+ }
78+ keys := dtoJSONKeys(t, resp)
79+ for _, key := range []string{"op", "app", "revision", "status", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} {
80+ assert.Contains(t, keys, key)
81+ }
82+ svc := keys["services"].(map[string]any)["web"].(map[string]any)
83+ for _, key := range []string{"result", "effective_revision", "restart_unsafe"} {
84+ assert.Contains(t, svc, key)
85+ }
86+}
87+
88+func TestAppShowResponseKeys(t *testing.T) {
89+ resp := AppShowResponse{
90+ App: "blog",
91+ Desired: AppDesiredDTO{Revision: "rev-new", Status: "pending", Pending: true},
92+ Active: AppActiveDTO{Converged: false, Services: map[string]AppActiveServiceDTO{
93+ "web": {EffectiveRevision: "rev-old", Digest: "sha256:abc", Container: "ctr-old", RestartUnsafe: true},
94+ }},
95+ Intent: AppIntentDTO{Stopped: false},
96+ LastOp: &AppLastOpDTO{Op: "op-abc", Outcome: "success"},
97+ }
98+ keys := dtoJSONKeys(t, resp)
99+ for _, key := range []string{"app", "desired", "active", "intent", "retained", "last_op"} {
100+ assert.Contains(t, keys, key)
101+ }
102+ web := keys["active"].(map[string]any)["services"].(map[string]any)["web"].(map[string]any)
103+ for _, key := range []string{"effective_revision", "digest", "container", "restart_unsafe"} {
104+ assert.Contains(t, web, key)
105+ }
106+ assert.NotContains(t, web, "env", "active services must not inline env values")
107+}
108+
109+// TestAppResponsesCarryNoSecretValues marshals representative responses and
110+// proves a sentinel secret value appears nowhere. The request that carries
111+// values (AppSecretSetRequest) is asserted as the single exception.
112+func TestAppResponsesCarryNoSecretValues(t *testing.T) {
113+ const sentinel = "s3cr3t-value-sentinel"
114+
115+ responses := []any{
116+ AppApplyResponse{App: "blog", ResultingRevision: "rev-x", Intent: "apply-x"},
117+ AppDeployResponse{
118+ Op: "op-x", App: "blog", Revision: "rev-x", Outcome: "failed",
119+ Services: map[string]AppServiceResultDTO{
120+ "web": {Result: "failed", EffectiveRevision: "rev-x", Error: "boom"},
121+ },
122+ Effective: &AppEffectiveDTO{Services: map[string]string{"web": "rev-x"}},
123+ Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
124+ },
125+ AppShowResponse{
126+ App: "blog",
127+ Desired: AppDesiredDTO{Revision: "rev-x", Status: "active"},
128+ Active: AppActiveDTO{Services: map[string]AppActiveServiceDTO{
129+ "web": {EffectiveRevision: "rev-x", Digest: "sha256:x", Container: "ctr-x"},
130+ }},
131+ },
132+ AppSummaryDTO{App: "blog", Desired: "rev-x", Active: "rev-x"},
133+ AppDiffResponse{App: "blog", Diff: AppDiffSection{Changed: []string{"secret.db/password"}}},
134+ }
135+ for i, resp := range responses {
136+ raw, err := json.Marshal(resp)
137+ require.NoError(t, err, "response %d", i)
138+ body := string(raw)
139+ assert.NotContains(t, body, sentinel, "response %d leaks a secret value", i)
140+ assert.NotContains(t, strings.ToLower(body), `"value"`, "response %d has a value field", i)
141+ }
142+
143+ // The one intentional exception: the set-secret request transports values.
144+ setReq := AppSecretSetRequest{Service: "web", Secrets: map[string]string{"password": sentinel}}
145+ raw, err := json.Marshal(setReq)
146+ require.NoError(t, err)
147+ assert.Contains(t, string(raw), sentinel)
148+}
D internal/adapters/dto/admin_attachments.go +0 -29
@@ -1,29 +0,0 @@
1-// Package dto provides shared data transfer objects for API responses.
2-package dto
3-
4-// AttachmentsConfigResponse represents all configured attachments.
5-type AttachmentsConfigResponse struct {
6- Attachments map[string][]string `json:"attachments"`
7-}
8-
9-// AttachmentConfigResponse represents attachments for a specific target.
10-type AttachmentConfigResponse struct {
11- Target string `json:"target"`
12- Images []string `json:"images"`
13-}
14-
15-// AttachmentTargetsByImageResponse represents attachment targets for a specific image.
16-type AttachmentTargetsByImageResponse struct {
17- Image string `json:"image"`
18- Targets []string `json:"targets"`
19-}
20-
21-// AttachmentAddRequest represents a request to add an attachment.
22-type AttachmentAddRequest struct {
23- Image string `json:"image"`
24-}
25-
26-// AttachmentStatusResponse represents attachment operation result.
27-type AttachmentStatusResponse struct {
28- Status string `json:"status"`
29-}
D internal/adapters/dto/admin_autoroute.go +0 -16
@@ -1,16 +0,0 @@
1-package dto
2-
3-// AutoRouteAllowedDomainsResponse represents the list of allowed domains.
4-type AutoRouteAllowedDomainsResponse struct {
5- Domains []string `json:"domains"`
6-}
7-
8-// AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern.
9-type AutoRouteAllowedDomainRequest struct {
10- Pattern string `json:"pattern"`
11-}
12-
13-// AutoRouteStatusResponse represents a status response for auto-route operations.
14-type AutoRouteStatusResponse struct {
15- Status string `json:"status"`
16-}
M internal/adapters/dto/admin_backups.go +30 -39
@@ -2,11 +2,13 @@ package dto
22
33 import "time"
44
5-// BackupJob represents backup metadata in admin API responses.
5+// BackupJob represents backup metadata in admin API responses. App is the
6+// canonical backup identity: a domain is never used.
67 type BackupJob struct {
78 ID string `json:"id"`
8- Domain string `json:"domain"`
9- DBName string `json:"db_name"`
9+ App string `json:"app"`
10+ Service string `json:"service,omitempty"`
11+ Database string `json:"database,omitempty"`
1012 Schedule string `json:"schedule,omitempty"`
1113 Type string `json:"type"`
1214 Status string `json:"status"`
@@ -21,9 +23,12 @@ type BackupsResponse struct {
2123 Backups []BackupJob `json:"backups"`
2224 }
2325
24-// BackupRunRequest triggers a backup run.
26+// BackupRunRequest triggers one declared database backup. Service and
27+// Database are explicit selectors; an omitted selector only succeeds when
28+// exactly one compatible target exists.
2529 type BackupRunRequest struct {
26- DB string `json:"db,omitempty"`
30+ Service string `json:"service"`
31+ Database string `json:"database,omitempty"`
2732 }
2833
2934 // BackupRunResponse is returned after triggering a backup.
@@ -32,38 +37,23 @@ type BackupRunResponse struct {
3237 Backup *BackupJob `json:"backup,omitempty"`
3338 }
3439
35-// DatabaseInfo represents a detected database attachment.
36-type DatabaseInfo struct {
37- Type string `json:"type"`
38- Name string `json:"name"`
39- Version string `json:"version,omitempty"`
40- Host string `json:"host"`
41- Port int `json:"port"`
42- ContainerID string `json:"container_id"`
43- ImageName string `json:"image_name"`
44-}
45-
46-// BackupDetectResponse is returned by detect endpoints.
47-type BackupDetectResponse struct {
48- Databases []DatabaseInfo `json:"databases"`
49-}
50-
51-// VolumeBackupJob represents volume backup metadata in admin API responses.
40+// VolumeBackupJob represents volume backup metadata in admin API
41+// responses. App, service, and the declared volume name are the identity.
5242 type VolumeBackupJob struct {
53- ID string `json:"id"`
54- Domain string `json:"domain"`
55- ContainerName string `json:"container_name,omitempty"`
56- ContainerID string `json:"container_id,omitempty"`
57- VolumeName string `json:"volume_name"`
58- MountPath string `json:"mount_path,omitempty"`
59- Compression string `json:"compression,omitempty"`
60- Type string `json:"type"`
61- Status string `json:"status"`
62- StartedAt *time.Time `json:"started_at,omitempty"`
63- CompletedAt *time.Time `json:"completed_at,omitempty"`
64- SizeBytes int64 `json:"size_bytes"`
65- ArtifactRef string `json:"artifact_ref,omitempty"`
66- Error string `json:"error,omitempty"`
43+ ID string `json:"id"`
44+ App string `json:"app"`
45+ Service string `json:"service,omitempty"`
46+ VolumeName string `json:"volume"`
47+ RuntimeVolumeName string `json:"runtime_volume,omitempty"`
48+ MountPath string `json:"mount_path,omitempty"`
49+ Compression string `json:"compression,omitempty"`
50+ Type string `json:"type"`
51+ Status string `json:"status"`
52+ StartedAt *time.Time `json:"started_at,omitempty"`
53+ CompletedAt *time.Time `json:"completed_at,omitempty"`
54+ SizeBytes int64 `json:"size_bytes"`
55+ ArtifactRef string `json:"artifact_ref,omitempty"`
56+ Error string `json:"error,omitempty"`
6757 }
6858
6959 // VolumeBackupsResponse is returned by volume backup listing endpoints.
@@ -71,12 +61,13 @@ type VolumeBackupsResponse struct {
7161 Backups []VolumeBackupJob `json:"backups"`
7262 }
7363
74-// VolumeBackupRunRequest triggers volume backups.
64+// VolumeBackupRunRequest triggers one declared volume backup.
7565 type VolumeBackupRunRequest struct {
76- Volume string `json:"volume,omitempty"`
66+ Service string `json:"service"`
67+ Volume string `json:"volume,omitempty"`
7768 }
7869
79-// VolumeBackupRunResponse is returned after triggering volume backups.
70+// VolumeBackupRunResponse is returned after triggering a volume backup.
8071 type VolumeBackupRunResponse struct {
8172 Status string `json:"status"`
8273 Backups []VolumeBackupJob `json:"backups,omitempty"`
A internal/adapters/dto/admin_backups_test.go +37 -0
@@ -0,0 +1,37 @@
1+package dto
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestBackupDTOsUseAppIdentity proves the wire shape identifies a backup
12+// by app, service, and database or volume: a domain is never part of it.
13+func TestBackupDTOsUseAppIdentity(t *testing.T) {
14+ job := BackupJob{ID: "job-1", App: "shop", Service: "api", Database: "orders", Schedule: "daily", Status: "completed"}
15+ volume := VolumeBackupJob{ID: "job-2", App: "shop", Service: "api", VolumeName: "data", RuntimeVolumeName: "gordon-shop--api--vol--data", Status: "completed"}
16+
17+ for name, payload := range map[string]any{"database job": job, "volume job": volume} {
18+ t.Run(name, func(t *testing.T) {
19+ raw, err := json.Marshal(payload)
20+ require.NoError(t, err)
21+ var decoded map[string]any
22+ require.NoError(t, json.Unmarshal(raw, &decoded))
23+ for _, key := range []string{"id", "app", "service"} {
24+ assert.Contains(t, decoded, key)
25+ }
26+ assert.NotContains(t, decoded, "domain", "a domain is never a backup identity")
27+ })
28+ }
29+
30+ runRequest, err := json.Marshal(BackupRunRequest{Service: "api", Database: "orders"})
31+ require.NoError(t, err)
32+ assert.JSONEq(t, `{"service":"api","database":"orders"}`, string(runRequest))
33+
34+ volumeRequest, err := json.Marshal(VolumeBackupRunRequest{Service: "api", Volume: "data"})
35+ require.NoError(t, err)
36+ assert.JSONEq(t, `{"service":"api","volume":"data"}`, string(volumeRequest))
37+}
D internal/adapters/dto/admin_bootstrap.go +0 -25
@@ -1,25 +0,0 @@
1-package dto
2-
3-// BootstrapRequest represents a bootstrap operation request.
4-type BootstrapRequest struct {
5- Domain string `json:"domain"`
6- Image string `json:"image"`
7- Attachments []string `json:"attachments,omitempty"`
8- Env map[string]string `json:"env,omitempty"`
9- AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"`
10-}
11-
12-// BootstrapStep represents the result of one bootstrap step.
13-type BootstrapStep struct {
14- Name string `json:"name"`
15- Status string `json:"status"` // "created", "configured", "updated", "noop", "failed"
16-}
17-
18-// BootstrapResponse represents the result of a bootstrap operation.
19-type BootstrapResponse struct {
20- Domain string `json:"domain"`
21- Image string `json:"image"`
22- Steps []BootstrapStep `json:"steps"`
23- Warnings []string `json:"warnings,omitempty"`
24- Next string `json:"next"`
25-}
M internal/adapters/dto/admin_config.go +2 -7
@@ -1,12 +1,12 @@
11 package dto
22
33 // ConfigResponse represents server configuration.
4+// ConfigResponse represents installation configuration. App routes live
5+// under apps show, never here.
46 type ConfigResponse struct {
57 Server ServerConfig `json:"server"`
6- AutoRoute AutoRouteConfig `json:"auto_route"`
78 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
89 Volumes VolumesConfig `json:"volumes"`
9- Routes []Route `json:"routes"`
1010 ExternalRoutes []ExternalRoute `json:"external_routes"`
1111 }
1212
@@ -18,11 +18,6 @@ type ServerConfig struct {
1818 DataDir string `json:"data_dir,omitempty"`
1919 }
2020
21-// AutoRouteConfig represents auto-route config details.
22-type AutoRouteConfig struct {
23- Enabled bool `json:"enabled"`
24-}
25-
2621 // NetworkIsolationConfig represents network isolation settings.
2722 type NetworkIsolationConfig struct {
2823 Enabled bool `json:"enabled"`
D internal/adapters/dto/admin_deploy.go +0 -8
@@ -1,8 +0,0 @@
1-package dto
2-
3-// DeployResponse represents a deployment response.
4-type DeployResponse struct {
5- Status string `json:"status"`
6- ContainerID string `json:"container_id"`
7- Domain string `json:"domain"`
8-}
M internal/adapters/dto/admin_images.go +6 -2
@@ -17,11 +17,13 @@ type ImagesResponse struct {
1717 Images []Image `json:"images"`
1818 }
1919
20-// ImagePruneRequest triggers image pruning.
20+// ImagePruneRequest triggers image pruning. DryRun plans the full
21+// operation and deletes nothing.
2122 type ImagePruneRequest struct {
2223 KeepLast *int `json:"keep_last,omitempty"`
2324 PruneDangling *bool `json:"prune_dangling,omitempty"`
2425 PruneRegistry *bool `json:"prune_registry,omitempty"`
26+ DryRun *bool `json:"dry_run,omitempty"`
2527 }
2628
2729 // RuntimePruneResult represents runtime prune results.
@@ -37,8 +39,10 @@ type RegistryPruneResult struct {
3739 SpaceReclaimed int64 `json:"space_reclaimed"`
3840 }
3941
40-// ImagePruneResponse is returned by image prune endpoints.
42+// ImagePruneResponse is returned by image prune endpoints. Plan is the
43+// same shape for dry runs and executions.
4144 type ImagePruneResponse struct {
4245 Runtime RuntimePruneResult `json:"runtime"`
4346 Registry RegistryPruneResult `json:"registry"`
47+ Plan PruneSummary `json:"plan"`
4448 }
D internal/adapters/dto/admin_restart.go +0 -7
@@ -1,7 +0,0 @@
1-package dto
2-
3-// RestartResponse represents a restart response.
4-type RestartResponse struct {
5- Status string `json:"status"`
6- Domain string `json:"domain"`
7-}
D internal/adapters/dto/admin_secrets.go +0 -19
@@ -1,19 +0,0 @@
1-package dto
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.
10-type SecretsListResponse struct {
11- Domain string `json:"domain"`
12- Keys []string `json:"keys"`
13- Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"`
14-}
15-
16-// SecretsStatusResponse represents a secrets write response.
17-type SecretsStatusResponse struct {
18- Status string `json:"status"`
19-}
M internal/adapters/dto/admin_status.go +1 -2
@@ -2,11 +2,10 @@ package dto
22
33 // StatusResponse represents admin status information.
44 type StatusResponse struct {
5- Routes int `json:"routes"`
5+ Apps int `json:"apps"`
66 RegistryDomain string `json:"registry_domain"`
77 RegistryPort int `json:"registry_port"`
88 ServerPort int `json:"server_port"`
9- AutoRoute bool `json:"auto_route"`
109 NetworkIsolation bool `json:"network_isolation"`
1110 ContainerStatuses map[string]string `json:"container_status"`
1211 }
A internal/adapters/dto/prune.go +102 -0
@@ -0,0 +1,102 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// PruneCandidate is one planned prune candidate with its verdict and the
6+// stable reason codes behind it.
7+type PruneCandidate struct {
8+ Kind string `json:"kind"`
9+ Ref string `json:"ref"`
10+ Verdict string `json:"verdict"`
11+ Reasons []string `json:"reasons"`
12+}
13+
14+// PruneGap is one inventory that could not be read in full. A gap never
15+// fails the operation; it makes the dependent candidates unknown.
16+type PruneGap struct {
17+ Source string `json:"source"`
18+ Reason string `json:"reason"`
19+ Detail string `json:"detail,omitempty"`
20+}
21+
22+// PruneFailure is one deletion that failed without aborting the run.
23+type PruneFailure struct {
24+ Kind string `json:"kind"`
25+ Ref string `json:"ref"`
26+ Error string `json:"error"`
27+}
28+
29+// PruneSummary is the shared prune report shape: dry-run and execution
30+// return the same structure, distinguished by Applied.
31+type PruneSummary struct {
32+ // Applied is false for a dry run, where nothing was deleted.
33+ Applied bool `json:"applied"`
34+ // Eligible, Protected, and Unknown count the planned candidates.
35+ Eligible int `json:"eligible"`
36+ Protected int `json:"protected"`
37+ Unknown int `json:"unknown"`
38+ // Candidates lists every planned candidate with its verdict, so an
39+ // operator can see why each resource was kept or skipped.
40+ Candidates []PruneCandidate `json:"candidates"`
41+ // Deleted lists exactly the identities that were removed.
42+ Deleted []PruneCandidate `json:"deleted"`
43+ // Failures lists deletions that failed without aborting the run.
44+ Failures []PruneFailure `json:"failures,omitempty"`
45+ // Gaps lists every inventory that could not be read in full.
46+ Gaps []PruneGap `json:"gaps,omitempty"`
47+ // ReclaimedBytes is set only when the adapter can attribute exact
48+ // bytes to the deletions it performed.
49+ ReclaimedBytes int64 `json:"reclaimed_bytes"`
50+ ReclaimedKnown bool `json:"reclaimed_known"`
51+}
52+
53+// CountByKind counts the eligible candidates of one resource kind.
54+func (s PruneSummary) CountByKind(kind domain.PruneResourceKind) int {
55+ count := 0
56+ for _, candidate := range s.Candidates {
57+ if candidate.Kind == string(kind) && candidate.Verdict == string(domain.PruneVerdictEligible) {
58+ count++
59+ }
60+ }
61+ return count
62+}
63+
64+// PruneSummaryFromDomain maps the domain prune report to its wire form.
65+// Dry-run and execution share the shape, distinguished by Applied.
66+func PruneSummaryFromDomain(report domain.PruneReport) PruneSummary {
67+ summary := PruneSummary{
68+ Applied: report.Applied,
69+ Eligible: report.CountByVerdict(domain.PruneVerdictEligible),
70+ Protected: report.CountByVerdict(domain.PruneVerdictProtected),
71+ Unknown: report.CountByVerdict(domain.PruneVerdictUnknown),
72+ ReclaimedBytes: report.ReclaimedBytes,
73+ ReclaimedKnown: report.ReclaimedKnown,
74+ }
75+ for _, candidate := range report.Candidates {
76+ summary.Candidates = append(summary.Candidates, pruneCandidateFromDomain(candidate))
77+ }
78+ for _, candidate := range report.Deleted {
79+ summary.Deleted = append(summary.Deleted, pruneCandidateFromDomain(candidate))
80+ }
81+ for _, failure := range report.Failures {
82+ summary.Failures = append(summary.Failures, PruneFailure{
83+ Kind: string(failure.Kind), Ref: failure.Ref, Error: failure.Err,
84+ })
85+ }
86+ for _, gap := range report.Gaps {
87+ summary.Gaps = append(summary.Gaps, PruneGap{
88+ Source: string(gap.Source), Reason: string(gap.Reason), Detail: gap.Detail,
89+ })
90+ }
91+ return summary
92+}
93+
94+func pruneCandidateFromDomain(candidate domain.PruneCandidateReport) PruneCandidate {
95+ reasons := make([]string, 0, len(candidate.Reasons))
96+ for _, reason := range candidate.Reasons {
97+ reasons = append(reasons, string(reason))
98+ }
99+ return PruneCandidate{
100+ Kind: string(candidate.Kind), Ref: candidate.Ref, Verdict: string(candidate.Verdict), Reasons: reasons,
101+ }
102+}
M internal/adapters/dto/volumes.go +5 -3
@@ -20,8 +20,10 @@ type VolumePruneRequest struct {
2020 }
2121
2222 // VolumePruneResponse contains the results of a volume prune operation.
23+// Plan is the same shape for dry runs and executions.
2324 type VolumePruneResponse struct {
24- VolumesRemoved int `json:"volumes_removed"`
25- SpaceReclaimed int64 `json:"space_reclaimed"`
26- Volumes []Volume `json:"volumes,omitempty"`
25+ VolumesRemoved int `json:"volumes_removed"`
26+ SpaceReclaimed int64 `json:"space_reclaimed"`
27+ Volumes []Volume `json:"volumes,omitempty"`
28+ Plan PruneSummary `json:"plan"`
2729 }
A internal/adapters/in/appmanifest/parse.go +418 -0
@@ -0,0 +1,418 @@
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+ "errors"
11+ "fmt"
12+ "maps"
13+ "slices"
14+ "strconv"
15+ "strings"
16+ "time"
17+
18+ "github.com/pelletier/go-toml/v2"
19+
20+ "github.com/bnema/gordon/internal/domain"
21+)
22+
23+// rawManifest mirrors the frozen TOML schema for strict decoding.
24+// Services is keyed by service name: [services.<name>].
25+type rawManifest struct {
26+ Name string `toml:"name"`
27+ Env map[string]string `toml:"env"`
28+ Services map[string]rawService `toml:"services"`
29+ Network rawNetwork `toml:"network"`
30+ Telemetry rawTelemetry `toml:"telemetry"`
31+}
32+
33+// rawTelemetry mirrors [telemetry] and [services.<name>.telemetry].
34+// A nil Logs means "inherit": service inherits the app, the app
35+// inherits the default (export on).
36+type rawTelemetry struct {
37+ Logs *bool `toml:"logs"`
38+}
39+
40+// rawNetwork mirrors [network] and its [[network.shared]] children.
41+type rawNetwork struct {
42+ Shared []rawSharedNetwork `toml:"shared"`
43+}
44+
45+// rawService mirrors one [services.<name>] table. The service name is
46+// the table key, so there is no name field inside the table.
47+type rawService struct {
48+ Image string `toml:"image"`
49+ Command []string `toml:"command"`
50+ StopGrace string `toml:"stop_grace"`
51+ Env map[string]string `toml:"env"`
52+ Readiness rawReadiness `toml:"readiness"`
53+ HTTP []rawHTTP `toml:"http"`
54+ TCP []rawTCP `toml:"tcp"`
55+ UDP []rawUDP `toml:"udp"`
56+ Secrets map[string]string `toml:"secrets"`
57+ Volumes []rawVolume `toml:"volume"`
58+ Binds []rawBind `toml:"bind"`
59+ // Devices lists logical device names from `devices = [...]`.
60+ Devices []string `toml:"devices"`
61+ Databases []rawDatabase `toml:"database"`
62+ Backup rawBackup `toml:"backup"`
63+ Telemetry rawTelemetry `toml:"telemetry"`
64+}
65+
66+// rawReadiness mirrors [services.<name>.readiness].
67+type rawReadiness struct {
68+ Type string `toml:"type"`
69+ Path string `toml:"path"`
70+ Contains string `toml:"contains"`
71+ Port int `toml:"port"`
72+ Timeout string `toml:"timeout"`
73+}
74+
75+// rawHTTP mirrors [[services.<name>.http]].
76+type rawHTTP struct {
77+ Host string `toml:"host"`
78+ Port int `toml:"port"`
79+ // TLS uses presence tracking because internal interfaces reject every
80+ // explicit value, including an empty string.
81+ TLS *string `toml:"tls"`
82+ // Visibility is empty when unset; the parser normalizes it to public.
83+ Visibility string `toml:"visibility"`
84+}
85+
86+// rawTCP mirrors [[services.<name>.tcp]].
87+type rawTCP struct {
88+ Entrypoint string `toml:"entrypoint"`
89+ Port int `toml:"port"`
90+ Publish string `toml:"publish"`
91+}
92+
93+// rawUDP mirrors [[services.<name>.udp]].
94+type rawUDP struct {
95+ Entrypoint string `toml:"entrypoint"`
96+ Port int `toml:"port"`
97+ Publish string `toml:"publish"`
98+}
99+
100+// rawVolume mirrors [[services.<name>.volume]].
101+type rawVolume struct {
102+ Name string `toml:"name"`
103+ Path string `toml:"path"`
104+ ReadOnly bool `toml:"readonly"`
105+}
106+
107+// rawBind mirrors [[services.<name>.bind]].
108+type rawBind struct {
109+ Name string `toml:"name"`
110+ Path string `toml:"path"`
111+ ReadOnly bool `toml:"readonly"`
112+}
113+
114+// rawDatabase mirrors [[services.<name>.database]].
115+type rawDatabase struct {
116+ Name string `toml:"name"`
117+ Type string `toml:"type"`
118+ Schedule string `toml:"schedule"`
119+}
120+
121+// rawBackup mirrors [services.<name>.backup].
122+type rawBackup struct {
123+ Postgres []string `toml:"postgres"`
124+ Volume []string `toml:"volume"`
125+}
126+
127+// rawSharedNetwork mirrors [[network.shared]].
128+type rawSharedNetwork struct {
129+ Network string `toml:"network"`
130+ Services []string `toml:"services"`
131+ Aliases []string `toml:"aliases"`
132+}
133+
134+// Parse decodes manifest TOML bytes into a validated, normalized AppSpec.
135+// sourceName is used for mismatch warnings only; the name field is authoritative.
136+func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
137+ var raw rawManifest
138+ decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
139+ if err := decoder.Decode(&raw); err != nil {
140+ if hint := legacyKeyedSchemaHint(data); hint != "" {
141+ return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, hint)
142+ }
143+ return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err))
144+ }
145+ spec, err := toDomain(raw)
146+ if err != nil {
147+ return domain.AppSpec{}, nil, err
148+ }
149+ var warnings []string
150+ if sourceName != "" && sourceName != spec.Name && sourceName != spec.Name+".toml" {
151+ warnings = append(warnings, fmt.Sprintf("file name %q does not match app name %q", sourceName, spec.Name))
152+ }
153+ if err := spec.Validate(); err != nil {
154+ return domain.AppSpec{}, warnings, err
155+ }
156+ return spec, warnings, nil
157+}
158+
159+func formatDecodeError(err error) string {
160+ var strictErr *toml.StrictMissingError
161+ if !errors.As(err, &strictErr) {
162+ return err.Error()
163+ }
164+ unknown := make([]string, 0, len(strictErr.Errors))
165+ for i := range strictErr.Errors {
166+ key := strictErr.Errors[i].Key()
167+ if len(key) > 0 {
168+ unknown = append(unknown, strings.Join(key, "."))
169+ }
170+ }
171+ if len(unknown) == 0 {
172+ return strictErr.Error()
173+ }
174+ return "unknown TOML fields or tables: " + strings.Join(unknown, ", ")
175+}
176+
177+// legacyKeyedSchemaHint recognizes the retired array-of-tables app shapes
178+// ([[service]] and [[services]]) and returns a stable, actionable
179+// diagnostic. It inspects the loosely decoded document rather than
180+// matching go-toml error wording, which is not a stable contract.
181+func legacyKeyedSchemaHint(data []byte) string {
182+ var doc map[string]any
183+ if err := toml.Unmarshal(data, &doc); err != nil {
184+ return ""
185+ }
186+ const fix = "array-of-tables is a retired app shape; use one [services.<name>] table per service (keyed schema), e.g. [services.web]"
187+ switch {
188+ case isArrayTable(doc["service"]):
189+ return "[[service]] " + fix
190+ case isArrayTable(doc["services"]):
191+ return "[[services]] " + fix
192+ default:
193+ return ""
194+ }
195+}
196+
197+// isArrayTable reports whether a loosely decoded value is the [[name]]
198+// TOML shape: a non-empty array whose elements are all tables. Scalar
199+// values, string arrays, and keyed tables are not array tables, so they
200+// keep the plain unknown-field diagnostic.
201+func isArrayTable(v any) bool {
202+ tables, ok := v.([]any)
203+ if !ok || len(tables) == 0 {
204+ return false
205+ }
206+ for _, table := range tables {
207+ if _, ok := table.(map[string]any); !ok {
208+ return false
209+ }
210+ }
211+ return true
212+}
213+
214+// toDomain maps raw TOML onto domain types with normalization and defaults.
215+func toDomain(raw rawManifest) (domain.AppSpec, error) {
216+ spec := domain.AppSpec{
217+ Name: raw.Name,
218+ Env: map[string]string{},
219+ Services: make([]domain.AppService, 0, len(raw.Services)),
220+ Networks: make([]domain.AppSharedNetwork, 0, len(raw.Network.Shared)),
221+ }
222+ for key, value := range raw.Env {
223+ spec.Env[key] = value
224+ }
225+ // Iterate the service map in sorted key order so that domain
226+ // conversion and every downstream projection are deterministic
227+ // regardless of TOML declaration order.
228+ for _, name := range slices.Sorted(maps.Keys(raw.Services)) {
229+ svc, err := toDomainService(name, raw.Services[name])
230+ if err != nil {
231+ return domain.AppSpec{}, err
232+ }
233+ svc.LogExportDisabled = !resolveLogExport(raw.Telemetry.Logs, raw.Services[name].Telemetry.Logs)
234+ spec.Services = append(spec.Services, svc)
235+ }
236+ for _, net := range raw.Network.Shared {
237+ spec.Networks = append(spec.Networks, domain.AppSharedNetwork{
238+ Network: net.Network,
239+ Services: append([]string(nil), net.Services...),
240+ Aliases: append([]string(nil), net.Aliases...),
241+ })
242+ }
243+ return spec, nil
244+}
245+
246+// resolveLogExport applies the precedence service > app > default (on).
247+func resolveLogExport(app, service *bool) bool {
248+ if service != nil {
249+ return *service
250+ }
251+ if app != nil {
252+ return *app
253+ }
254+ return true
255+}
256+
257+func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
258+ visibility := h.Visibility
259+ if visibility == "" {
260+ visibility = domain.AppVisibilityPublic
261+ }
262+ if visibility == domain.AppVisibilityInternal && h.TLS != nil {
263+ return domain.AppHTTPInterface{}, fmt.Errorf("%w: service %q internal http port %d must not declare tls", domain.ErrInvalidAppSpec, service, h.Port)
264+ }
265+ tls := ""
266+ if h.TLS != nil {
267+ tls = *h.TLS
268+ } else if visibility == domain.AppVisibilityPublic {
269+ tls = domain.AppTLSAuto
270+ }
271+ return domain.AppHTTPInterface{
272+ Host: domain.CanonicalHTTPHost(h.Host), Port: h.Port, TLS: tls, Visibility: visibility,
273+ }, nil
274+}
275+
276+// toDomainService maps one raw service with defaults. serviceName is the
277+// [services.<name>] table key and becomes the service identity.
278+func toDomainService(serviceName string, raw rawService) (domain.AppService, error) {
279+ svc := domain.AppService{
280+ Name: serviceName,
281+ Image: normalizeImage(raw.Image),
282+ Command: append([]string(nil), raw.Command...),
283+ Secrets: map[string]string{},
284+ Backup: domain.AppBackup{
285+ Postgres: append([]string(nil), raw.Backup.Postgres...),
286+ Volume: append([]string(nil), raw.Backup.Volume...),
287+ },
288+ }
289+ stopGrace := domain.AppDefaultStopGrace
290+ if raw.StopGrace != "" {
291+ parsed, err := time.ParseDuration(raw.StopGrace)
292+ if err != nil {
293+ return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, serviceName, raw.StopGrace, err)
294+ }
295+ stopGrace = parsed
296+ }
297+ svc.StopGrace = stopGrace
298+ readiness, err := toDomainReadiness(serviceName, raw.Readiness)
299+ if err != nil {
300+ return domain.AppService{}, err
301+ }
302+ svc.Readiness = readiness
303+ for _, h := range raw.HTTP {
304+ iface, err := toDomainHTTP(serviceName, h)
305+ if err != nil {
306+ return domain.AppService{}, err
307+ }
308+ svc.HTTP = append(svc.HTTP, iface)
309+ }
310+ for _, t := range raw.TCP {
311+ svc.TCP = append(svc.TCP, domain.AppTCPInterface{
312+ Entrypoint: t.Entrypoint,
313+ Port: t.Port,
314+ Publish: t.Publish,
315+ })
316+ }
317+ for _, u := range raw.UDP {
318+ svc.UDP = append(svc.UDP, domain.AppUDPInterface{
319+ Entrypoint: u.Entrypoint,
320+ Port: u.Port,
321+ Publish: u.Publish,
322+ })
323+ }
324+ for key, value := range raw.Secrets {
325+ svc.Secrets[key] = value
326+ }
327+ for _, v := range raw.Volumes {
328+ svc.Volumes = append(svc.Volumes, domain.AppVolume{
329+ Name: v.Name,
330+ Path: v.Path,
331+ ReadOnly: v.ReadOnly,
332+ })
333+ }
334+ for _, b := range raw.Binds {
335+ svc.Binds = append(svc.Binds, domain.AppBind{
336+ Name: b.Name,
337+ Path: b.Path,
338+ ReadOnly: b.ReadOnly,
339+ })
340+ }
341+ // Preserve manifest order; domain equalDevices treats the slice as a
342+ // set, so reorder-only input is a no-op in diffs.
343+ svc.Devices = append([]string(nil), raw.Devices...)
344+ for _, db := range raw.Databases {
345+ svc.Databases = append(svc.Databases, domain.AppDatabase{
346+ Name: db.Name,
347+ Type: db.Type,
348+ Schedule: db.Schedule,
349+ })
350+ }
351+ if len(raw.Env) > 0 {
352+ return domain.AppService{}, fmt.Errorf("%w: service %q [services.%s.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, serviceName, tomlKey(serviceName))
353+ }
354+ return svc, nil
355+}
356+
357+// toDomainReadiness maps readiness with defaults.
358+func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
359+ readiness := domain.AppReadiness{
360+ Type: raw.Type,
361+ Path: raw.Path,
362+ Contains: raw.Contains,
363+ Port: raw.Port,
364+ Timeout: domain.AppDefaultReadinessTimeout,
365+ }
366+ if readiness.Type == "" {
367+ readiness.Type = domain.AppReadinessNone
368+ }
369+ if raw.Timeout != "" {
370+ parsed, err := time.ParseDuration(raw.Timeout)
371+ if err != nil {
372+ return domain.AppReadiness{}, fmt.Errorf("%w: service %q readiness timeout %q is invalid: %v", domain.ErrInvalidAppSpec, service, raw.Timeout, err)
373+ }
374+ readiness.Timeout = parsed
375+ }
376+ return readiness, nil
377+}
378+
379+// tomlKey renders a service name as a TOML key, quoting it when it is
380+// not a bare key (service names may contain dots, e.g. web.api).
381+func tomlKey(name string) string {
382+ if isBareTOMLKey(name) {
383+ return name
384+ }
385+ return strconv.Quote(name)
386+}
387+
388+// isBareTOMLKey reports whether name is a TOML bare key (A-Za-z0-9_-).
389+func isBareTOMLKey(name string) bool {
390+ if name == "" {
391+ return false
392+ }
393+ for _, r := range name {
394+ switch {
395+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
396+ default:
397+ return false
398+ }
399+ }
400+ return true
401+}
402+
403+// normalizeImage records a missing tag as explicit :latest.
404+func normalizeImage(image string) string {
405+ image = strings.TrimSpace(image)
406+ if image == "" {
407+ return image
408+ }
409+ if strings.Contains(image, "@") {
410+ return image
411+ }
412+ lastSlash := strings.LastIndex(image, "/")
413+ lastColon := strings.LastIndex(image, ":")
414+ if lastColon <= lastSlash {
415+ return image + ":latest"
416+ }
417+ return image
418+}
A internal/adapters/in/appmanifest/parse_test.go +605 -0
@@ -0,0 +1,605 @@
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+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const validWeb = `
15+name = "blog"
16+
17+[env]
18+APP_ENV = "production"
19+
20+[services.web]
21+image = "registry.example.com/blog/web:1.4.2"
22+
23+[services.web.readiness]
24+type = "http"
25+path = "/healthz"
26+
27+[[services.web.http]]
28+host = "Blog.Example.COM."
29+port = 8080
30+
31+[services.web.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, "web", svc.Name)
44+ assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image)
45+ assert.Equal(t, "blog.example.com", svc.HTTP[0].Host)
46+ assert.Equal(t, "auto", svc.HTTP[0].TLS)
47+ assert.Equal(t, "http", svc.Readiness.Type)
48+}
49+
50+func TestParse_SharedNetwork(t *testing.T) {
51+ doc := `
52+name = "media"
53+[services.web]
54+image = "registry.example.com/media/web:1"
55+[[network.shared]]
56+network = "backend"
57+services = ["web"]
58+aliases = ["media-web"]
59+`
60+
61+ spec, warnings, err := appmanifest.Parse([]byte(doc), "media.toml")
62+ require.NoError(t, err)
63+ assert.Empty(t, warnings)
64+ require.Len(t, spec.Networks, 1)
65+ assert.Equal(t, domain.AppSharedNetwork{
66+ Network: "backend", Services: []string{"web"}, Aliases: []string{"media-web"},
67+ }, spec.Networks[0])
68+}
69+
70+func TestParse_FileNameMismatchIsWarning(t *testing.T) {
71+ _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")
72+ require.NoError(t, err)
73+ require.Len(t, warnings, 1)
74+ assert.Contains(t, warnings[0], "other.toml")
75+}
76+
77+func TestParse_MissingTagNormalizesToLatest(t *testing.T) {
78+ doc := `
79+name = "blog"
80+[services.web]
81+image = "registry.example.com/blog/web"
82+[[services.web.http]]
83+host = "blog.example.com"
84+port = 8080
85+`
86+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
87+ require.NoError(t, err)
88+ assert.Equal(t, "registry.example.com/blog/web:latest", spec.Services[0].Image)
89+}
90+
91+func TestParse_UnknownFieldsRejected(t *testing.T) {
92+ cases := []struct {
93+ name string
94+ doc string
95+ }{
96+ {"top level", "name = \"blog\"\nbogus = 1\n[services.web]\nimage = \"img:1\"\n"},
97+ {"service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nbogus = 1\n"},
98+ {"readiness", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.readiness]\nbogus = 1\n"},
99+ {"http", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"},
100+ }
101+ for _, tc := range cases {
102+ t.Run(tc.name, func(t *testing.T) {
103+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
104+ require.Error(t, err)
105+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
106+ })
107+ }
108+}
109+
110+// TestParse_OldSyntaxRejected proves the retired array-of-tables schema
111+// and its name field are no longer accepted, with no compatibility shim.
112+func TestParse_OldSyntaxRejected(t *testing.T) {
113+ cases := []struct {
114+ name string
115+ doc string
116+ wantErr string
117+ }{
118+ {
119+ "legacy service array",
120+ "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n",
121+ "[[service]] array-of-tables is a retired app shape",
122+ },
123+ {
124+ "legacy plural service array",
125+ "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n",
126+ "[[services]] array-of-tables is a retired app shape",
127+ },
128+ {
129+ "service name as field",
130+ "name = \"blog\"\n[services.web]\nname = \"web\"\nimage = \"img:1\"\n",
131+ "unknown TOML fields or tables: services.web.name",
132+ },
133+ }
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
137+ require.Error(t, err)
138+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
139+ assert.ErrorContains(t, err, tc.wantErr)
140+ })
141+ }
142+}
143+
144+// TestParse_RetiredArraySyntaxHintIsActionable proves the rejected
145+// [[service]] and [[services]] shapes carry the same stable keyed-schema
146+// remedy instead of leaking go-toml decoding wording.
147+func TestParse_RetiredArraySyntaxHintIsActionable(t *testing.T) {
148+ docs := map[string]string{
149+ "singular": "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n",
150+ "plural": "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n",
151+ }
152+ for name, doc := range docs {
153+ t.Run(name, func(t *testing.T) {
154+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
155+ require.Error(t, err)
156+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
157+ assert.ErrorContains(t, err, "keyed schema")
158+ assert.ErrorContains(t, err, "[services.<name>]")
159+ assert.ErrorContains(t, err, "[services.web]")
160+ })
161+ }
162+}
163+
164+// TestParse_NonArrayTablesKeepPlainDiagnostic proves the keyed-schema hint
165+// stays reserved for real array-of-tables shapes: a [service] table, a
166+// scalar service value, and a string array under services are not array
167+// tables, so they keep the plain unknown-field diagnostic instead of
168+// being described as [[service]]/[[services]].
169+func TestParse_NonArrayTablesKeepPlainDiagnostic(t *testing.T) {
170+ cases := []struct {
171+ name string
172+ doc string
173+ wantErr string
174+ }{
175+ {
176+ "service table",
177+ "name = \"blog\"\n[service]\nimage = \"img:1\"\n",
178+ "unknown TOML fields or tables: service",
179+ },
180+ {
181+ "service scalar",
182+ "name = \"blog\"\nservice = \"web\"\n",
183+ "unknown TOML fields or tables: service",
184+ },
185+ {
186+ // services is a keyed table, so an array value fails to decode.
187+ // Assert only that the diagnostic names the key, never go-toml's
188+ // internal wording.
189+ "services string array",
190+ "name = \"blog\"\nservices = [\"web\"]\n",
191+ "services",
192+ },
193+ }
194+ for _, tc := range cases {
195+ t.Run(tc.name, func(t *testing.T) {
196+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
197+ require.Error(t, err)
198+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
199+ assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.wantErr))
200+ assert.NotContains(t, err.Error(), "array-of-tables is a retired app shape")
201+ })
202+ }
203+}
204+
205+// TestParse_UnknownNestedFieldRejected proves strict decoding still
206+// reports the dotted path of an unknown table or field under a service.
207+func TestParse_UnknownNestedFieldRejected(t *testing.T) {
208+ cases := []struct {
209+ name string
210+ doc string
211+ wantErr string
212+ }{
213+ {
214+ "unknown protocol table",
215+ "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.rcon]]\nport = 28016\n",
216+ "unknown TOML fields or tables: services.web.rcon",
217+ },
218+ {
219+ "unknown nested table",
220+ "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.extra]\nkey = \"value\"\n",
221+ "unknown TOML fields or tables: services.web.extra",
222+ },
223+ }
224+ for _, tc := range cases {
225+ t.Run(tc.name, func(t *testing.T) {
226+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
227+ require.Error(t, err)
228+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
229+ assert.ErrorContains(t, err, tc.wantErr)
230+ })
231+ }
232+}
233+
234+// TestParse_QuotedDottedServiceKey proves a service name containing a
235+// dot must be quoted, decodes as one key, and is usable by nested tables.
236+func TestParse_QuotedDottedServiceKey(t *testing.T) {
237+ doc := `
238+name = "blog"
239+[services."web.api"]
240+image = "registry.example.com/blog/api:1"
241+[[services."web.api".http]]
242+host = "api.example.com"
243+port = 8080
244+`
245+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
246+ require.NoError(t, err)
247+ require.Len(t, spec.Services, 1)
248+ assert.Equal(t, "web.api", spec.Services[0].Name)
249+ require.Len(t, spec.Services[0].HTTP, 1)
250+ assert.Equal(t, "api.example.com", spec.Services[0].HTTP[0].Host)
251+}
252+
253+// TestParse_UnquotedDottedKeyRejected proves an unquoted dotted key is
254+// read as nested tables, not as one service name, and is rejected.
255+func TestParse_UnquotedDottedKeyRejected(t *testing.T) {
256+ doc := `
257+name = "blog"
258+[services.web.api]
259+image = "registry.example.com/blog/api:1"
260+`
261+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
262+ require.Error(t, err)
263+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
264+ assert.ErrorContains(t, err, "unknown TOML fields or tables: services.web.api")
265+}
266+
267+// TestParse_DuplicateServiceTableRejected proves the TOML decoder rejects
268+// a repeated [services.<name>] table before domain validation runs.
269+func TestParse_DuplicateServiceTableRejected(t *testing.T) {
270+ doc := `
271+name = "blog"
272+[services.web]
273+image = "img:1"
274+[services.web]
275+image = "img:2"
276+`
277+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
278+ require.Error(t, err)
279+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
280+ assert.ErrorContains(t, err, "already exists")
281+}
282+
283+// TestParse_ServiceOrderDeterministic proves map keys are sorted before
284+// domain conversion so spec.Services order never depends on TOML order.
285+func TestParse_ServiceOrderDeterministic(t *testing.T) {
286+ doc := `
287+name = "blog"
288+[services.zeta]
289+image = "img:1"
290+[services.alpha]
291+image = "img:2"
292+[services.mid]
293+image = "img:3"
294+`
295+ for i := 0; i < 3; i++ {
296+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
297+ require.NoError(t, err)
298+ require.Len(t, spec.Services, 3)
299+ assert.Equal(t, []string{"alpha", "mid", "zeta"}, []string{
300+ spec.Services[0].Name, spec.Services[1].Name, spec.Services[2].Name,
301+ })
302+ }
303+}
304+
305+func TestParse_ServiceEnvRejected(t *testing.T) {
306+ cases := []struct {
307+ name string
308+ doc string
309+ wantErr string
310+ }{
311+ {
312+ "bare service name",
313+ "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n",
314+ "[services.web.env]",
315+ },
316+ {
317+ "dotted service name is quoted",
318+ "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n",
319+ `[services."web.api".env]`,
320+ },
321+ }
322+ for _, tc := range cases {
323+ t.Run(tc.name, func(t *testing.T) {
324+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
325+ require.Error(t, err)
326+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
327+ require.ErrorContains(t, err, tc.wantErr)
328+ })
329+ }
330+}
331+
332+func TestParse_Names(t *testing.T) {
333+ cases := []struct {
334+ name string
335+ doc string
336+ wantErr string
337+ }{
338+ {"bad app", "name = \"Blog!\"\n[services.web]\nimage = \"img:1\"\n", "app name"},
339+ {"double dash app", "name = \"a--b\"\n[services.web]\nimage = \"img:1\"\n", "--"},
340+ {"reserved app", "name = \"gordon\"\n[services.web]\nimage = \"img:1\"\n", "reserved"},
341+ {"no services", "name = \"blog\"\n", "at least one service"},
342+ {"dup service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web]\nimage = \"img:2\"\n", "already exists"},
343+ {"dup service case", "name = \"blog\"\n[services.Web]\nimage = \"img:1\"\n", "must match"},
344+ {"normalized collision", "name = \"blog\"\n[services.\"a.b\"]\nimage = \"img:1\"\n[services.\"a-b\"]\nimage = \"img:2\"\n", "same runtime identifier"},
345+ {"replicas rejected", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: services.web.replicas"},
346+ {"volume double dash", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
347+ {"shared volume", "name = \"blog\"\n[services.a]\nimage = \"img:1\"\n[[services.a.volume]]\nname = \"d\"\npath = \"/data\"\n[services.b]\nimage = \"img:1\"\n[[services.b.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"},
348+ {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[services.web]\nimage = \"img:1\"\n[services.web.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"},
349+ {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[services.web]\nimage = \"img:1\"\n", "secret references"},
350+ }
351+ for _, tc := range cases {
352+ t.Run(tc.name, func(t *testing.T) {
353+ _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
354+ require.ErrorContains(t, err, tc.wantErr)
355+ })
356+ }
357+}
358+
359+func TestParse_Readiness(t *testing.T) {
360+ cases := []struct {
361+ name string
362+ doc string
363+ wantErr string
364+ }{
365+ {"bad type", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"},
366+ {"log needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"},
367+ {"http needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"http\"\n", "http readiness requires path"},
368+ {"udp tcp rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "UDP-only"},
369+ {"multi tcp needs port", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "readiness.port is required"},
370+ {"bad port ref", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"},
371+ {"bad timeout", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntimeout = \"99h\"\n", "timeout must be within"},
372+ }
373+ for _, tc := range cases {
374+ t.Run(tc.name, func(t *testing.T) {
375+ _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
376+ require.ErrorContains(t, err, tc.wantErr)
377+ })
378+ }
379+}
380+
381+func TestParse_Interfaces(t *testing.T) {
382+ cases := []struct {
383+ name string
384+ doc string
385+ wantErr string
386+ }{
387+ {"tcp needs entrypoint", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
388+ {"publish hostname", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"},
389+ {"rcon table rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "unknown TOML fields or tables: services.w.rcon"},
390+ {"bad host", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
391+ }
392+ for _, tc := range cases {
393+ t.Run(tc.name, func(t *testing.T) {
394+ _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
395+ require.ErrorContains(t, err, tc.wantErr)
396+ })
397+ }
398+}
399+
400+func TestParse_Backups(t *testing.T) {
401+ doc := `
402+name = "shop"
403+[services.api]
404+image = "registry.example.com/shop/api:2.0.0"
405+[[services.api.http]]
406+host = "shop.example.com"
407+port = 8080
408+[[services.api.database]]
409+name = "main"
410+type = "postgres"
411+schedule = "daily"
412+[services.api.backup]
413+postgres = ["main"]
414+`
415+ spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml")
416+ require.NoError(t, err)
417+ assert.Equal(t, "daily", spec.Services[0].Databases[0].Schedule)
418+
419+ badRef := strings.Replace(doc, "postgres = [\"main\"]", "postgres = [\"ghost\"]", 1)
420+ _, _, err = appmanifest.Parse([]byte(badRef), "shop.toml")
421+ require.ErrorContains(t, err, "unknown database")
422+
423+ badSchedule := strings.Replace(doc, "schedule = \"daily\"", "schedule = \"minutely\"", 1)
424+ _, _, err = appmanifest.Parse([]byte(badSchedule), "shop.toml")
425+ require.ErrorContains(t, err, "schedule must be")
426+
427+ badEngine := strings.Replace(doc, "type = \"postgres\"", "type = \"mysql\"", 1)
428+ _, _, err = appmanifest.Parse([]byte(badEngine), "shop.toml")
429+ require.ErrorContains(t, err, "must be postgres")
430+}
431+
432+func TestParse_GameAndStagingExamples(t *testing.T) {
433+ game := `
434+name = "rust"
435+[services.server]
436+image = "registry.example.com/games/rust:2026.09"
437+[services.server.readiness]
438+type = "log"
439+path = "/data/logs/server.log"
440+contains = "Server startup complete"
441+timeout = "5m"
442+[[services.server.udp]]
443+entrypoint = "udp"
444+port = 28015
445+publish = "0.0.0.0:28015"
446+[[services.server.tcp]]
447+entrypoint = "tcp"
448+port = 28016
449+publish = "127.0.0.1:28016"
450+[[services.server.volume]]
451+name = "rust-data"
452+path = "/data"
453+`
454+ spec, _, err := appmanifest.Parse([]byte(game), "rust.toml")
455+ require.NoError(t, err)
456+ assert.Equal(t, "log", spec.Services[0].Readiness.Type)
457+ require.Len(t, spec.Services[0].TCP, 1)
458+ assert.Equal(t, 28016, spec.Services[0].TCP[0].Port)
459+
460+ staging := `
461+name = "blog-staging"
462+[env]
463+APP_ENV = "staging"
464+[services.web]
465+image = "registry.example.com/blog/web:1.4.2-rc.1"
466+[[services.web.http]]
467+host = "staging-blog.example.com"
468+port = 8080
469+`
470+ spec, _, err = appmanifest.Parse([]byte(staging), "blog-staging.toml")
471+ require.NoError(t, err)
472+ assert.Equal(t, "blog-staging", spec.Name)
473+}
474+
475+func TestParse_NoFileEnvSecretReads(t *testing.T) {
476+ // Parser input is bytes only; there is no path parameter that could
477+ // trigger file, env, or secret reads beyond the manifest input.
478+ spec, _, err := appmanifest.Parse([]byte(validWeb), "")
479+ require.NoError(t, err)
480+ assert.Equal(t, "blog", spec.Name)
481+}
482+
483+func TestParse_Binds(t *testing.T) {
484+ doc := `
485+name = "blog"
486+[services.web]
487+image = "registry.example.com/blog/web:1.4.2"
488+[[services.web.bind]]
489+name = "config"
490+path = "/etc/app.conf"
491+readonly = true
492+[[services.web.bind]]
493+name = "data"
494+path = "/var/lib/app"
495+`
496+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
497+ require.NoError(t, err)
498+ require.Len(t, spec.Services[0].Binds, 2)
499+ assert.Equal(t, domain.AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: true}, spec.Services[0].Binds[0])
500+ assert.Equal(t, domain.AppBind{Name: "data", Path: "/var/lib/app"}, spec.Services[0].Binds[1])
501+}
502+
503+func TestParse_BindDestinationRejected(t *testing.T) {
504+ doc := `
505+name = "blog"
506+[services.web]
507+image = "registry.example.com/blog/web:1.4.2"
508+[[services.web.bind]]
509+name = "dev"
510+path = "/dev"
511+`
512+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
513+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
514+ assert.Contains(t, err.Error(), "sensitive container path")
515+}
516+
517+func TestParse_Devices(t *testing.T) {
518+ doc := `
519+name = "demo"
520+[services.worker]
521+image = "registry.example.com/demo/worker:1"
522+devices = ["test_gpu"]
523+`
524+ spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
525+ require.NoError(t, err)
526+ require.Len(t, spec.Services, 1)
527+ assert.Equal(t, []string{"test_gpu"}, spec.Services[0].Devices)
528+}
529+
530+func TestParse_DevicesWrongType(t *testing.T) {
531+ doc := `
532+name = "demo"
533+[services.worker]
534+image = "registry.example.com/demo/worker:1"
535+devices = "test_gpu"
536+`
537+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
538+ require.Error(t, err)
539+}
540+
541+func TestParse_DevicesMalformedRejected(t *testing.T) {
542+ doc := `
543+name = "demo"
544+[services.worker]
545+image = "registry.example.com/demo/worker:1"
546+devices = ["Bad Name!"]
547+`
548+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
549+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
550+}
551+
552+func TestParse_DevicesDuplicateRejected(t *testing.T) {
553+ doc := `
554+name = "demo"
555+[services.worker]
556+image = "registry.example.com/demo/worker:1"
557+devices = ["test_gpu", "test_gpu"]
558+`
559+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
560+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
561+ assert.Contains(t, err.Error(), "duplicate device")
562+}
563+
564+func TestParse_UnknownGPUFieldRejected(t *testing.T) {
565+ doc := `
566+name = "demo"
567+[services.worker]
568+image = "registry.example.com/demo/worker:1"
569+gpus = "all"
570+`
571+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
572+ require.Error(t, err)
573+}
574+
575+func TestParse_TelemetryLogsPrecedence(t *testing.T) {
576+ const manifest = `
577+name = "blog"
578+
579+[telemetry]
580+logs = false
581+
582+[services.web]
583+image = "registry.example.com/blog/web:1"
584+
585+[services.web.telemetry]
586+logs = true
587+
588+[services.db]
589+image = "postgres:17"
590+`
591+ spec, _, err := appmanifest.Parse([]byte(manifest), "blog.toml")
592+ require.NoError(t, err)
593+ byName := map[string]domain.AppService{}
594+ for _, svc := range spec.Services {
595+ byName[svc.Name] = svc
596+ }
597+ assert.False(t, byName["web"].LogExportDisabled, "service override wins")
598+ assert.True(t, byName["db"].LogExportDisabled, "service inherits app")
599+}
600+
601+func TestParse_TelemetryLogsDefaultOn(t *testing.T) {
602+ spec, _, err := appmanifest.Parse([]byte(validWeb), "blog.toml")
603+ require.NoError(t, err)
604+ assert.False(t, spec.Services[0].LogExportDisabled)
605+}
A internal/adapters/in/appmanifest/parse_visibility_test.go +148 -0
@@ -0,0 +1,148 @@
1+package appmanifest_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/appmanifest"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TestParse_VisibilityNormalization proves absent visibility becomes
14+// explicit public, an internal interface keeps its TLS absent, and a
15+// public interface still defaults an absent TLS to auto.
16+func TestParse_VisibilityNormalization(t *testing.T) {
17+ t.Run("absent visibility stays public with auto tls", func(t *testing.T) {
18+ doc := `
19+name = "blog"
20+[services.web]
21+image = "registry.example.com/blog/web:1.0.0"
22+[[services.web.http]]
23+host = "blog.example.com"
24+port = 8080
25+`
26+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
27+ require.NoError(t, err)
28+ require.Len(t, spec.Services[0].HTTP, 1)
29+ iface := spec.Services[0].HTTP[0]
30+ assert.Equal(t, domain.AppVisibilityPublic, iface.Visibility)
31+ assert.Equal(t, domain.AppTLSAuto, iface.TLS)
32+ })
33+
34+ t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) {
35+ doc := `
36+name = "blog"
37+[services.api]
38+image = "registry.example.com/blog/api:1.0.0"
39+[services.api.readiness]
40+type = "http"
41+path = "/healthz"
42+[[services.api.http]]
43+visibility = "internal"
44+port = 8080
45+`
46+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
47+ require.NoError(t, err)
48+ require.Len(t, spec.Services[0].HTTP, 1)
49+ iface := spec.Services[0].HTTP[0]
50+ assert.Equal(t, domain.AppVisibilityInternal, iface.Visibility)
51+ assert.Empty(t, iface.Host)
52+ assert.Empty(t, iface.TLS, "an internal interface must not inherit the public auto tls default")
53+ assert.True(t, spec.Services[0].InternallyOnlyPort(8080))
54+ })
55+
56+ t.Run("explicit public normalizes to auto tls", func(t *testing.T) {
57+ doc := `
58+name = "blog"
59+[services.web]
60+image = "registry.example.com/blog/web:1.0.0"
61+[[services.web.http]]
62+host = "blog.example.com"
63+port = 8080
64+visibility = "public"
65+`
66+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
67+ require.NoError(t, err)
68+ assert.Equal(t, domain.AppVisibilityPublic, spec.Services[0].HTTP[0].Visibility)
69+ assert.Equal(t, domain.AppTLSAuto, spec.Services[0].HTTP[0].TLS)
70+ })
71+}
72+
73+// TestParse_VisibilityRejections proves invalid combinations fail at
74+// validation with the sentinel error.
75+func TestParse_VisibilityRejections(t *testing.T) {
76+ tests := map[string]string{
77+ "internal forbids host": `
78+name = "blog"
79+[services.api]
80+image = "registry.example.com/blog/api:1.0.0"
81+[[services.api.http]]
82+host = "blog.example.com"
83+port = 8080
84+visibility = "internal"
85+`,
86+ "internal forbids tls": `
87+name = "blog"
88+[services.api]
89+image = "registry.example.com/blog/api:1.0.0"
90+[[services.api.http]]
91+port = 8080
92+visibility = "internal"
93+tls = "auto"
94+`,
95+ "internal forbids explicitly empty tls": `
96+name = "blog"
97+[services.api]
98+image = "registry.example.com/blog/api:1.0.0"
99+[[services.api.http]]
100+port = 8080
101+visibility = "internal"
102+tls = ""
103+`,
104+ "unknown visibility value": `
105+name = "blog"
106+[services.web]
107+image = "registry.example.com/blog/web:1.0.0"
108+[[services.web.http]]
109+host = "blog.example.com"
110+port = 8080
111+visibility = "vpn"
112+`,
113+ "internal port also published as tcp": `
114+name = "blog"
115+[services.api]
116+image = "registry.example.com/blog/api:1.0.0"
117+[[services.api.http]]
118+port = 8080
119+visibility = "internal"
120+[[services.api.tcp]]
121+port = 8080
122+`,
123+ }
124+ for name, doc := range tests {
125+ t.Run(name, func(t *testing.T) {
126+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
127+ require.Error(t, err)
128+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
129+ })
130+ }
131+}
132+
133+// TestParse_VisibilityUnknownFieldStillRejected proves the parser stays
134+// strict: a near-miss key is still a decode error.
135+func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) {
136+ doc := `
137+name = "blog"
138+[services.web]
139+image = "registry.example.com/blog/web:1.0.0"
140+[[services.web.http]]
141+host = "blog.example.com"
142+port = 8080
143+visibilities = "internal"
144+`
145+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
146+ require.Error(t, err)
147+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
148+}
A internal/adapters/in/cli/apps.go +1221 -0
@@ -0,0 +1,1221 @@
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+ "github.com/bnema/gordon/internal/domain"
23+)
24+
25+// appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).
26+const appApplyMaxBytes = 1 << 20
27+
28+// newLocalAppClient constructs the owner-only local daemon client. It is a
29+// variable so command tests can inject a socket-backed or unavailable client.
30+var newLocalAppClient = remote.NewLocalClient
31+
32+// resolveAppClient returns the daemon client for app operations. An explicit
33+// remote (flag or GORDON_REMOTE) is authoritative and never falls back to the
34+// local socket; otherwise the owner-only local admin socket is used.
35+func resolveAppClient() (*remote.Client, error) {
36+ client, isRemote, err := GetRemoteClient()
37+ if err != nil {
38+ return nil, err
39+ }
40+ if isRemote {
41+ return client, nil
42+ }
43+
44+ localClient, err := newLocalAppClient()
45+ if err != nil {
46+ return nil, fmt.Errorf(
47+ "daemon-unavailable: no daemon endpoint is reachable; app operations "+
48+ "are daemon-owned and have no local-write fallback "+
49+ "(start the daemon or pass --remote): %w", err)
50+ }
51+ return localClient, nil
52+}
53+
54+// resolveAppPlane returns the daemon-backed control plane for app commands.
55+// App operations are daemon-owned for BOTH local and remote paths
56+// (05-api-cli.md §1): the explicit remote when one is selected, otherwise
57+// the owner-only local admin socket. When neither is reachable the command
58+// fails with daemon-unavailable — there is no local-write fallback. The
59+// returned handle owns the client and must be closed by the caller.
60+func resolveAppPlane() (*controlPlaneHandle, error) {
61+ client, err := resolveAppClient()
62+ if err != nil {
63+ return nil, err
64+ }
65+ return &controlPlaneHandle{plane: client}, nil
66+}
67+
68+// appMutationError translates ambiguous transport outcomes into the
69+// outcome-unknown guidance: never blindly retry, re-query by key first.
70+func appMutationError(op, app, key string, err error) error {
71+ var unknown *remote.OutcomeUnknownError
72+ if errors.As(err, &unknown) {
73+ return fmt.Errorf(
74+ "outcome-unknown: %s %s may have executed; run the by-key lookup "+
75+ "gordon apps operations show %s --key %s before retrying with the same key: %w",
76+ op, app, app, key, err)
77+ }
78+ return err
79+}
80+
81+// renderAppOpConflict renders the journaled 409 deploy/lifecycle response
82+// carried by remote.AppOpConflictError, then returns the terminal failure
83+// (nonzero exit) with the operation key and by-key recovery guidance. It
84+// reports false when err is not a journaled conflict so callers fall
85+// through to appMutationError.
86+func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut bool) (error, bool) {
87+ var conflict *remote.AppOpConflictError
88+ if !errors.As(err, &conflict) {
89+ return nil, false
90+ }
91+ if jsonOut {
92+ if werr := writeJSON(out, conflict.Response); werr != nil {
93+ return werr, true
94+ }
95+ } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {
96+ return rerr, true
97+ }
98+ return appOpConflictMessage(op, app, key, conflict), true
99+}
100+
101+// appOpConflictMessage is the by-key recovery guidance shared by the human
102+// and JSON renderings of a journaled 409 conflict.
103+func appOpConflictMessage(op, app, key string, conflict *remote.AppOpConflictError) error {
104+ return fmt.Errorf(
105+ "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+
106+ "gordon apps operations show %s --key %s before retrying with the same key: %w",
107+ op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, conflict)
108+}
109+
110+// newAppsCmd creates the `apps` parent command.
111+func newAppsCmd() *cobra.Command {
112+ cmd := &cobra.Command{
113+ Use: "apps",
114+ Short: "Manage applications (daemon-owned)",
115+ Long: `Validate, persist, inspect, and operate applications.
116+
117+All mutations are executed by the daemon through the admin API;
118+without a reachable daemon the commands fail instead of writing locally.
119+
120+Examples:
121+ gordon apps apply --file blog.toml
122+ gordon apps list
123+ gordon apps show blog
124+ gordon apps diff blog`,
125+ }
126+ cmd.AddCommand(
127+ newAppsApplyCmd(),
128+ newAppsOperationsCmd(),
129+ newAppsListCmd(),
130+ newAppsShowCmd(),
131+ newAppsDiffCmd(),
132+ newAppsSecretsCmd(),
133+ newAppDeployCmd(),
134+ newAppRestartCmd(),
135+ newAppStopCmd(),
136+ newAppStartCmd(),
137+ newAppRemoveCmd(),
138+ newAppStatusCmd(),
139+ newAppLogsCmd(),
140+ )
141+ return cmd
142+}
143+
144+// newAppsApplyCmd creates `apps apply`.
145+func newAppsApplyCmd() *cobra.Command {
146+ var file string
147+ var dryRun bool
148+ var chainDeploy bool
149+ var jsonOut bool
150+ cmd := &cobra.Command{
151+ Use: "apply",
152+ Short: "Validate and persist an app manifest",
153+ Long: `Validates a manifest file and persists it as desired state (or dry-runs).
154+
155+With --deploy, chains exactly the accepted revision into a deploy after
156+persistence succeeds; the two outcomes are reported separately because a
157+deploy may fail after the apply succeeded.`,
158+ RunE: func(cmd *cobra.Command, args []string) error {
159+ handle, err := resolveAppPlane()
160+ if err != nil {
161+ return err
162+ }
163+ defer handle.close()
164+ plane := handle.plane
165+ return runAppsApply(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), file, dryRun, chainDeploy, jsonOut)
166+ },
167+ }
168+ cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")
169+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate without persisting")
170+ cmd.Flags().BoolVar(&chainDeploy, "deploy", false, "Deploy the accepted revision after applying")
171+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
172+ return cmd
173+}
174+
175+func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out, errOut io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error {
176+ if dryRun && chainDeploy {
177+ return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")
178+ }
179+ if file == "" {
180+ return fmt.Errorf("missing required flag --file")
181+ }
182+ data, err := os.ReadFile(file)
183+ if err != nil {
184+ return fmt.Errorf("failed to read manifest file: %w", err)
185+ }
186+ if len(data) > appApplyMaxBytes {
187+ return fmt.Errorf("manifest exceeds the 1 MiB apply bound (%d bytes)", len(data))
188+ }
189+ resp, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: string(data), DryRun: dryRun})
190+ if err != nil {
191+ return err
192+ }
193+ if !chainDeploy {
194+ if jsonOut {
195+ return writeJSON(out, resp)
196+ }
197+ return renderAppApply(out, resp, dryRun)
198+ }
199+ if jsonOut {
200+ return runAppsApplyDeployJSON(ctx, plane, resp, out, errOut)
201+ }
202+ if err := renderAppApply(out, resp, dryRun); err != nil {
203+ return err
204+ }
205+ return runAppsApplyDeploy(ctx, plane, resp, out, errOut)
206+}
207+
208+// appApplyDeployDocument is the single machine-readable apply+deploy result.
209+// The apply is always included so a failed chained deploy never hides that
210+// the persist itself succeeded.
211+type appApplyDeployDocument struct {
212+ Apply *dto.AppApplyResponse `json:"apply"`
213+ Deploy *dto.AppDeployResponse `json:"deploy"`
214+}
215+
216+// runAppsApplyDeploy follows a chained deploy in human mode. The apply outcome
217+// is already rendered, then an accepted (202 running) deploy reuses the shared
218+// by-key watch: progress only on change, terminal journal, and Ctrl-C guidance
219+// that the daemon-side operation keeps running. A terminal partial/failed
220+// outcome exits nonzero without hiding the successful apply.
221+func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
222+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
223+ if err != nil {
224+ if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {
225+ return applySucceededError(apply, conflictErr)
226+ }
227+ return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
228+ }
229+ if deployResp.Status == dto.AppStatusRunning {
230+ if werr := watchOperation(ctx, plane, apply.App, key, deployResp, out, errOut, false); werr != nil {
231+ return applySucceededError(apply, werr)
232+ }
233+ return nil
234+ }
235+ return renderAppDeployResponse(out, deployResp)
236+}
237+
238+// runAppsApplyDeployJSON follows a chained deploy and writes exactly one final
239+// {apply,deploy} document once the operation is terminal. The mutation is
240+// issued once and observed through the existing by-key endpoint; no initial
241+// running document is emitted, and progress, transient warnings, and Ctrl-C
242+// resume guidance go to errOut so stdout stays machine-readable.
243+func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
244+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
245+ if err != nil {
246+ var conflict *remote.AppOpConflictError
247+ if errors.As(err, &conflict) {
248+ if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: &conflict.Response}); werr != nil {
249+ return werr
250+ }
251+ return applySucceededError(apply, appOpConflictMessage("deploy", apply.App, key, conflict))
252+ }
253+ return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
254+ }
255+ terminal := deployResp
256+ if deployResp.Status == dto.AppStatusRunning {
257+ op, werr := awaitOperation(ctx, plane, apply.App, key, deployResp, errOut)
258+ if werr != nil {
259+ return applySucceededError(apply, werr)
260+ }
261+ terminal = op
262+ }
263+ if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: terminal}); werr != nil {
264+ return werr
265+ }
266+ if terminal.Outcome == domain.AppOutcomeSuccess {
267+ return nil
268+ }
269+ return applySucceededError(apply, &OperationFailedError{App: apply.App, Op: terminal.Op, Outcome: terminal.Outcome})
270+}
271+
272+// applySucceededError preserves the apply outcome in every chained-deploy
273+// failure path: the desired state was already persisted, so the caller must
274+// not be told the apply failed.
275+func applySucceededError(apply *dto.AppApplyResponse, cause error) error {
276+ return fmt.Errorf("apply of %s succeeded (%s); %w", apply.App, apply.ResultingRevision, cause)
277+}
278+
279+func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {
280+ if resp.Noop {
281+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))
282+ }
283+ verb := "Applied"
284+ if dryRun {
285+ verb = "Validated (dry-run)"
286+ }
287+ if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("%s %s%s", verb, resp.App, appRevisionSuffix(resp.ResultingRevision)))); err != nil {
288+ return err
289+ }
290+ if err := renderAppDiffSection(out, resp.Diff); err != nil {
291+ return err
292+ }
293+ if resp.Pending {
294+ return cliWriteLine(out, cliRenderMeta("intent:", resp.Intent))
295+ }
296+ return nil
297+}
298+
299+// appRevisionSuffix omits the parenthesized revision when empty (dry-run
300+// validation may not produce a revision).
301+func appRevisionSuffix(revision string) string {
302+ if revision == "" {
303+ return ""
304+ }
305+ return " (" + revision + ")"
306+}
307+
308+// renderAppDiffSection renders normalized diff paths using the same
309+// added/removed/changed sections as `apps diff`; empty diffs render nothing.
310+func renderAppDiffSection(out io.Writer, diff dto.AppDiffSection) error {
311+ if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0 {
312+ return nil
313+ }
314+ for _, section := range []struct {
315+ title string
316+ paths []string
317+ }{
318+ {"added:", diff.Added},
319+ {"removed:", diff.Removed},
320+ {"changed:", diff.Changed},
321+ } {
322+ paths := append([]string(nil), section.paths...)
323+ sort.Strings(paths)
324+ for _, p := range paths {
325+ if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
326+ return err
327+ }
328+ }
329+ }
330+ return nil
331+}
332+
333+// newAppsListCmd creates `apps list`.
334+func newAppsListCmd() *cobra.Command {
335+ var jsonOut bool
336+ cmd := &cobra.Command{
337+ Use: "list",
338+ Short: "List applications",
339+ RunE: func(cmd *cobra.Command, args []string) error {
340+ handle, err := resolveAppPlane()
341+ if err != nil {
342+ return err
343+ }
344+ defer handle.close()
345+ plane := handle.plane
346+ return runAppsList(cmd.Context(), plane, cmd.OutOrStdout(), jsonOut)
347+ },
348+ }
349+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
350+ return cmd
351+}
352+
353+func runAppsList(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
354+ apps, err := plane.ListApps(ctx)
355+ if err != nil {
356+ return err
357+ }
358+ if jsonOut {
359+ if apps == nil {
360+ apps = []dto.AppSummaryDTO{}
361+ }
362+ return writeJSON(out, apps)
363+ }
364+ if len(apps) == 0 {
365+ return cliWriteLine(out, cliRenderEmptyState("No apps."))
366+ }
367+ names := make([]string, 0, len(apps))
368+ byName := make(map[string]dto.AppSummaryDTO, len(apps))
369+ for _, a := range apps {
370+ names = append(names, a.App)
371+ byName[a.App] = a
372+ }
373+ sort.Strings(names)
374+ for _, name := range names {
375+ a := byName[name]
376+ state := "active"
377+ switch {
378+ case a.Stopped:
379+ state = "stopped"
380+ case a.Pending:
381+ state = "pending"
382+ case a.Active == "":
383+ state = "applied"
384+ case a.LastOutcome != "" && a.LastOutcome != "success":
385+ state += " (" + a.LastOutcome + ")"
386+ }
387+ if err := cliWriteLine(out, cliRenderListItem(fmt.Sprintf("%s (%s)", name, state))); err != nil {
388+ return err
389+ }
390+ }
391+ return nil
392+}
393+
394+// newAppsShowCmd creates `apps show`.
395+func newAppsShowCmd() *cobra.Command {
396+ var jsonOut bool
397+ cmd := &cobra.Command{
398+ Use: "show APP",
399+ Short: "Show desired and active state for an app",
400+ Args: cobra.ExactArgs(1),
401+ RunE: func(cmd *cobra.Command, args []string) error {
402+ handle, err := resolveAppPlane()
403+ if err != nil {
404+ return err
405+ }
406+ defer handle.close()
407+ plane := handle.plane
408+ return runAppsShow(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
409+ },
410+ }
411+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
412+ return cmd
413+}
414+
415+func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
416+ resp, err := plane.ShowApp(ctx, app)
417+ if err != nil {
418+ return err
419+ }
420+ if jsonOut {
421+ return writeJSON(out, resp)
422+ }
423+ if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
424+ return err
425+ }
426+ if err := cliWriteLine(out, cliRenderMeta("desired:", resp.Desired.Revision+" ("+resp.Desired.Status+")")); err != nil {
427+ return err
428+ }
429+ for _, line := range renderActiveServices(resp.Active) {
430+ if err := cliWriteLine(out, line); err != nil {
431+ return err
432+ }
433+ }
434+ if resp.Intent.Stopped {
435+ if err := cliWriteLine(out, cliRenderWarning("stopped intent is set")); err != nil {
436+ return err
437+ }
438+ }
439+ if resp.LastOp != nil && resp.LastOp.Op != "" {
440+ if err := cliWriteLine(out, cliRenderMeta("last-op:", resp.LastOp.Op+" ("+resp.LastOp.Outcome+")")); err != nil {
441+ return err
442+ }
443+ }
444+ owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
445+ owned = append(owned, resp.Retained.Secrets...)
446+ if len(owned) > 0 {
447+ sort.Strings(owned)
448+ return cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " ")))
449+ }
450+ return nil
451+}
452+
453+// renderActiveServices renders per-service effective state, sorted by name.
454+// Only ids and digests appear — never secret values.
455+func renderActiveServices(active dto.AppActiveDTO) []string {
456+ names := make([]string, 0, len(active.Services))
457+ for name := range active.Services {
458+ names = append(names, name)
459+ }
460+ sort.Strings(names)
461+ lines := make([]string, 0, len(names)+1)
462+ converged := "converged"
463+ if !active.Converged {
464+ converged = "diverged"
465+ }
466+ lines = append(lines, cliRenderMeta("active:", converged))
467+ for _, name := range names {
468+ svc := active.Services[name]
469+ detail := svc.EffectiveRevision
470+ if svc.Container != "" {
471+ detail += " container=" + svc.Container
472+ }
473+ if svc.RestartUnsafe {
474+ detail += " restart_unsafe"
475+ }
476+ lines = append(lines, cliRenderMeta(" "+name+":", detail))
477+ }
478+ return lines
479+}
480+
481+// newAppsDiffCmd creates `apps diff`.
482+func newAppsDiffCmd() *cobra.Command {
483+ var jsonOut bool
484+ cmd := &cobra.Command{
485+ Use: "diff APP",
486+ Short: "Show the normalized desired-vs-active diff",
487+ Args: cobra.ExactArgs(1),
488+ RunE: func(cmd *cobra.Command, args []string) error {
489+ handle, err := resolveAppPlane()
490+ if err != nil {
491+ return err
492+ }
493+ defer handle.close()
494+ plane := handle.plane
495+ return runAppsDiff(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
496+ },
497+ }
498+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
499+ return cmd
500+}
501+
502+func runAppsDiff(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
503+ resp, err := plane.DiffApp(ctx, app)
504+ if err != nil {
505+ return err
506+ }
507+ if jsonOut {
508+ return writeJSON(out, resp)
509+ }
510+ if len(resp.Diff.Added) == 0 && len(resp.Diff.Removed) == 0 && len(resp.Diff.Changed) == 0 {
511+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No differences for %s.", app)))
512+ }
513+ for _, section := range []struct {
514+ title string
515+ paths []string
516+ }{
517+ {"added:", resp.Diff.Added},
518+ {"removed:", resp.Diff.Removed},
519+ {"changed:", resp.Diff.Changed},
520+ } {
521+ paths := append([]string(nil), section.paths...)
522+ sort.Strings(paths)
523+ for _, p := range paths {
524+ if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
525+ return err
526+ }
527+ }
528+ }
529+ return nil
530+}
531+
532+// newAppsSecretsCmd creates the `apps secrets` parent command.
533+func newAppsSecretsCmd() *cobra.Command {
534+ cmd := &cobra.Command{
535+ Use: "secrets",
536+ Short: "Manage app secret values",
537+ Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
538+--stdin with KEY=VALUE lines, or --stdin --key KEY for one raw value. Names
539+must already exist in desired or active state. Only key names are ever echoed
540+back — never values.`,
541+ }
542+ cmd.AddCommand(newAppsSecretsListCmd(), newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
543+ return cmd
544+}
545+
546+func newAppsSecretsListCmd() *cobra.Command {
547+ var service string
548+ var jsonOut bool
549+ cmd := &cobra.Command{Use: "list APP", Short: "List app secret registration metadata", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
550+ handle, err := resolveAppPlane()
551+ if err != nil {
552+ return err
553+ }
554+ defer handle.close()
555+ return runAppsSecretsList(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], service, jsonOut)
556+ }}
557+ cmd.Flags().StringVar(&service, "service", "", "Filter by service")
558+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
559+ return cmd
560+}
561+
562+// runAppsSecretsList renders metadata-only registrations for one app,
563+// optionally filtered to one service. Secret values never appear.
564+func runAppsSecretsList(ctx context.Context, plane ControlPlane, out io.Writer, app, service string, jsonOut bool) error {
565+ entries, err := plane.ListAppSecrets(ctx, app, service)
566+ if err != nil {
567+ return fmt.Errorf("list app secrets for %s: %w", app, err)
568+ }
569+ if jsonOut {
570+ return writeJSON(out, entries)
571+ }
572+ for _, entry := range entries {
573+ if err := cliWriteLine(out, cliRenderMeta(entry.Service+"/"+entry.Key+":", entry.Name+" "+entry.Source+" "+entry.Presence)); err != nil {
574+ return err
575+ }
576+ }
577+ if len(entries) == 0 {
578+ return cliWriteLine(out, cliRenderMuted("No registered secrets"))
579+ }
580+ return nil
581+}
582+
583+// newAppsSecretsSetCmd creates `apps secrets set`.
584+func newAppsSecretsSetCmd() *cobra.Command {
585+ var service string
586+ var key string
587+ var fromStdin bool
588+ var jsonOut bool
589+ cmd := &cobra.Command{
590+ Use: "set APP KEY=VALUE…",
591+ Short: "Write app secret values",
592+ Args: cobra.MinimumNArgs(0),
593+ RunE: func(cmd *cobra.Command, args []string) error {
594+ handle, err := resolveAppPlane()
595+ if err != nil {
596+ return err
597+ }
598+ defer handle.close()
599+ plane := handle.plane
600+ if len(args) == 0 {
601+ return fmt.Errorf("missing APP argument")
602+ }
603+ return runAppsSecretsSetMode(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), args[0], args[1:], service, key, fromStdin, jsonOut)
604+ },
605+ }
606+ cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")
607+ cmd.Flags().StringVar(&key, "key", "", "Secret key for single-value stdin mode")
608+ cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines, or one raw value with --key")
609+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
610+ return cmd
611+}
612+
613+func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {
614+ return runAppsSecretsSetMode(ctx, plane, stdin, out, app, pairs, service, "", fromStdin, jsonOut)
615+}
616+
617+func runAppsSecretsSetMode(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service, key string, fromStdin, jsonOut bool) error {
618+ if app == "" {
619+ return fmt.Errorf("missing APP argument")
620+ }
621+ if service == "" {
622+ return fmt.Errorf("missing required flag --service: secrets are service-scoped")
623+ }
624+ if key != "" {
625+ if !fromStdin || len(pairs) != 0 {
626+ return fmt.Errorf("--key requires --stdin and forbids KEY=VALUE arguments")
627+ }
628+ value, err := readRawSecretStdin(stdin)
629+ if err != nil {
630+ return err
631+ }
632+ pairs = []string{key + "=" + value}
633+ } else if fromStdin {
634+ stdinPairs, err := readSecretStdin(stdin)
635+ if err != nil {
636+ return err
637+ }
638+ pairs = append(pairs, stdinPairs...)
639+ }
640+ values, err := parseSecretPairs(pairs)
641+ if err != nil {
642+ return err
643+ }
644+ if len(values) == 0 {
645+ return fmt.Errorf("no secrets provided: pass KEY=VALUE arguments or --stdin")
646+ }
647+ if err := plane.SetAppSecrets(ctx, app, dto.AppSecretSetRequest{Service: service, Secrets: values}); err != nil {
648+ return err
649+ }
650+ keys := make([]string, 0, len(values))
651+ for k := range values {
652+ keys = append(keys, k)
653+ }
654+ sort.Strings(keys)
655+ if jsonOut {
656+ return writeJSON(out, map[string]any{"app": app, "service": service, "keys": keys})
657+ }
658+ if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Set %d secret(s) for %s/%s: %s", len(keys), app, service, strings.Join(keys, ", ")))); err != nil {
659+ return err
660+ }
661+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("Running containers keep their old values. Apply them with: gordon apps deploy %s --service %s", app, service)))
662+}
663+
664+// newAppsSecretsDeleteCmd creates `apps secrets delete`.
665+func newAppsSecretsDeleteCmd() *cobra.Command {
666+ var service string
667+ var jsonOut bool
668+ cmd := &cobra.Command{
669+ Use: "delete APP KEY",
670+ Short: "Delete an app secret value",
671+ Args: cobra.ExactArgs(2),
672+ RunE: func(cmd *cobra.Command, args []string) error {
673+ handle, err := resolveAppPlane()
674+ if err != nil {
675+ return err
676+ }
677+ defer handle.close()
678+ plane := handle.plane
679+ return runAppsSecretsDelete(cmd.Context(), plane, cmd.OutOrStdout(), args[0], args[1], service, jsonOut)
680+ },
681+ }
682+ cmd.Flags().StringVar(&service, "service", "", "Service the secret belongs to (required)")
683+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
684+ return cmd
685+}
686+
687+func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer, app, key, service string, jsonOut bool) error {
688+ if service == "" {
689+ return fmt.Errorf("missing required flag --service: secrets are service-scoped")
690+ }
691+ if key == "" {
692+ return fmt.Errorf("missing KEY argument")
693+ }
694+ if err := plane.DeleteAppSecret(ctx, app, dto.AppSecretDeleteRequest{Service: service, Key: key}); err != nil {
695+ return err
696+ }
697+ if jsonOut {
698+ return writeJSON(out, map[string]any{"app": app, "service": service, "deleted": key})
699+ }
700+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
701+}
702+
703+// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty and
704+// each value must pass validateSecretValue (1-MaxAppEnvValueLen bytes on a
705+// single line); an empty value is rejected.
706+func parseSecretPairs(pairs []string) (map[string]string, error) {
707+ values := make(map[string]string, len(pairs))
708+ for index, pair := range pairs {
709+ key, value, ok := strings.Cut(pair, "=")
710+ if !ok || key == "" {
711+ return nil, fmt.Errorf("invalid secret input %d: expected KEY=VALUE", index+1)
712+ }
713+ if err := validateSecretValue(value); err != nil {
714+ return nil, fmt.Errorf("invalid secret input %d: %w", index+1, err)
715+ }
716+ values[key] = value
717+ }
718+ return values, nil
719+}
720+
721+func validateSecretValue(value string) error {
722+ if value == "" || len(value) > domain.MaxAppEnvValueLen {
723+ return fmt.Errorf("value must be 1-%d bytes", domain.MaxAppEnvValueLen)
724+ }
725+ if strings.ContainsAny(value, "\r\n") {
726+ return fmt.Errorf("value must be a single line")
727+ }
728+ return nil
729+}
730+
731+func readRawSecretStdin(in io.Reader) (string, error) {
732+ data, err := io.ReadAll(io.LimitReader(in, domain.MaxAppEnvValueLen+3))
733+ if err != nil {
734+ return "", fmt.Errorf("failed to read secret from stdin: %w", err)
735+ }
736+ if strings.HasSuffix(string(data), "\r\n") {
737+ data = data[:len(data)-2]
738+ } else if strings.HasSuffix(string(data), "\n") {
739+ data = data[:len(data)-1]
740+ }
741+ value := string(data)
742+ if err := validateSecretValue(value); err != nil {
743+ return "", fmt.Errorf("invalid stdin secret: %w", err)
744+ }
745+ return value, nil
746+}
747+
748+const maxSecretStdinBytes = 1 << 20
749+
750+// readSecretStdin reads bounded KEY=VALUE lines. Whitespace-only lines are
751+// ignored, while every byte in nonblank values is preserved.
752+func readSecretStdin(in io.Reader) ([]string, error) {
753+ data, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1))
754+ if err != nil {
755+ return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
756+ }
757+ if len(data) > maxSecretStdinBytes {
758+ return nil, fmt.Errorf("secret input exceeds %d bytes", maxSecretStdinBytes)
759+ }
760+ var pairs []string
761+ for _, line := range strings.Split(string(data), "\n") {
762+ line = strings.TrimSuffix(line, "\r")
763+ if strings.TrimSpace(line) != "" {
764+ pairs = append(pairs, line)
765+ }
766+ }
767+ return pairs, nil
768+}
769+
770+// newAppDeployCmd creates `deploy APP`. At cutover this replaces the
771+// domain-based deploy for apps; domain-based deploy keeps working for
772+// non-app resources until then (05-api-cli.md §5).
773+func newAppDeployCmd() *cobra.Command {
774+ var revision string
775+ var service string
776+ var all bool
777+ var jsonOut bool
778+ cmd := &cobra.Command{
779+ Use: "deploy APP",
780+ Short: "Activate an app revision",
781+ Args: cobra.ExactArgs(1),
782+ RunE: func(cmd *cobra.Command, args []string) error {
783+ handle, err := resolveAppPlane()
784+ if err != nil {
785+ return err
786+ }
787+ defer handle.close()
788+ plane := handle.plane
789+ req := dto.AppDeployRequest{Revision: revision, Service: service, All: all}
790+ return runAppDeploy(cmd.Context(), plane, args[0], req, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
791+ },
792+ }
793+ cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
794+ cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
795+ cmd.Flags().BoolVar(&all, "all", false, "Deploy every service (required for multi-service apps without --service)")
796+ cmd.MarkFlagsMutuallyExclusive("service", "all")
797+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
798+ return cmd
799+}
800+
801+// runAppDeploy issues one deploy mutation and, when the daemon answers 202
802+// with a running journal, polls the existing by-key endpoint to terminal
803+// rather than reissuing the mutation or hiding the outcome.
804+func runAppDeploy(ctx context.Context, plane ControlPlane, app string, req dto.AppDeployRequest, out, errOut io.Writer, jsonOut bool) error {
805+ resp, key, err := plane.DeployApp(ctx, app, req)
806+ if err != nil {
807+ if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
808+ return conflictErr
809+ }
810+ return appMutationError("deploy", app, key, err)
811+ }
812+ if resp.Status == dto.AppStatusRunning {
813+ return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut)
814+ }
815+ if jsonOut {
816+ return writeJSON(out, resp)
817+ }
818+ return renderAppDeployResponse(out, resp)
819+}
820+
821+// newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
822+func newAppRestartCmd() *cobra.Command {
823+ var service string
824+ var all bool
825+ var jsonOut bool
826+ cmd := &cobra.Command{
827+ Use: "restart APP",
828+ Short: "Restart an app from pinned digests",
829+ Args: cobra.ExactArgs(1),
830+ RunE: func(cmd *cobra.Command, args []string) error {
831+ handle, err := resolveAppPlane()
832+ if err != nil {
833+ return err
834+ }
835+ defer handle.close()
836+ plane := handle.plane
837+ return runAppRestart(cmd.Context(), plane, args[0], service, all, cmd.OutOrStdout(), jsonOut)
838+ },
839+ }
840+ cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
841+ cmd.Flags().BoolVar(&all, "all", false, "Restart every service (required for multi-service apps without --service)")
842+ cmd.MarkFlagsMutuallyExclusive("service", "all")
843+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
844+ return cmd
845+}
846+
847+func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, all bool, out io.Writer, jsonOut bool) error {
848+ resp, key, err := plane.RestartApp(ctx, app, service, all)
849+ if err != nil {
850+ if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
851+ return conflictErr
852+ }
853+ return appMutationError("restart", app, key, err)
854+ }
855+ if jsonOut {
856+ return writeJSON(out, resp)
857+ }
858+ return renderAppDeployResponse(out, resp)
859+}
860+
861+// newAppStopCmd creates `stop APP`: durable stopped intent, data preserved.
862+func newAppStopCmd() *cobra.Command {
863+ var jsonOut bool
864+ cmd := &cobra.Command{
865+ Use: "stop APP",
866+ Short: "Stop an app (preserves all data)",
867+ Args: cobra.ExactArgs(1),
868+ RunE: func(cmd *cobra.Command, args []string) error {
869+ handle, err := resolveAppPlane()
870+ if err != nil {
871+ return err
872+ }
873+ defer handle.close()
874+ plane := handle.plane
875+ return runAppLifecycle(cmd.Context(), plane.StopApp, "stop", args[0], cmd.OutOrStdout(), jsonOut)
876+ },
877+ }
878+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
879+ return cmd
880+}
881+
882+// newAppStartCmd creates `start APP`: clears intent, ensures running.
883+func newAppStartCmd() *cobra.Command {
884+ var jsonOut bool
885+ cmd := &cobra.Command{
886+ Use: "start APP",
887+ Short: "Start a stopped app from active state",
888+ Args: cobra.ExactArgs(1),
889+ RunE: func(cmd *cobra.Command, args []string) error {
890+ handle, err := resolveAppPlane()
891+ if err != nil {
892+ return err
893+ }
894+ defer handle.close()
895+ plane := handle.plane
896+ return runAppLifecycle(cmd.Context(), plane.StartApp, "start", args[0], cmd.OutOrStdout(), jsonOut)
897+ },
898+ }
899+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
900+ return cmd
901+}
902+
903+// newAppRemoveCmd creates `remove APP`: withdraws workloads, retains data.
904+func newAppRemoveCmd() *cobra.Command {
905+ var jsonOut bool
906+ cmd := &cobra.Command{
907+ Use: "remove APP",
908+ Short: "Remove app workloads (volumes and secrets are retained)",
909+ Args: cobra.ExactArgs(1),
910+ RunE: func(cmd *cobra.Command, args []string) error {
911+ handle, err := resolveAppPlane()
912+ if err != nil {
913+ return err
914+ }
915+ defer handle.close()
916+ plane := handle.plane
917+ return runAppLifecycle(cmd.Context(), plane.RemoveApp, "remove", args[0], cmd.OutOrStdout(), jsonOut)
918+ },
919+ }
920+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
921+ return cmd
922+}
923+
924+// appLifecycleFunc is one body-less app lifecycle mutation.
925+type appLifecycleFunc func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
926+
927+func runAppLifecycle(ctx context.Context, fn appLifecycleFunc, op, app string, out io.Writer, jsonOut bool) error {
928+ resp, key, err := fn(ctx, app)
929+ if err != nil {
930+ if conflictErr, ok := renderAppOpConflict(out, op, app, key, err, jsonOut); ok {
931+ return conflictErr
932+ }
933+ return appMutationError(op, app, key, err)
934+ }
935+ if jsonOut {
936+ return writeJSON(out, resp)
937+ }
938+ return renderAppDeployResponse(out, resp)
939+}
940+
941+// renderAppDeployResponse renders the deploy outcome with terminal
942+// per-service results, cleanup warnings, and the effective/retained
943+// summaries. Sorts services for stable output.
944+func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
945+ if err := renderDeployHeadline(out, resp); err != nil {
946+ return err
947+ }
948+ if err := renderDeployServices(out, resp); err != nil {
949+ return err
950+ }
951+ if err := renderDeploySteps(out, resp.Steps); err != nil {
952+ return err
953+ }
954+ return renderDeploySummaries(out, resp)
955+}
956+
957+func renderDeploySteps(out io.Writer, steps []dto.AppStepDTO) error {
958+ for _, step := range steps {
959+ detail := step.State
960+ if step.Detail != "" {
961+ detail += ": " + sanitizeTerminalText(step.Detail)
962+ }
963+ if step.Error != "" {
964+ detail += ": " + sanitizeTerminalText(step.Error)
965+ }
966+ if err := cliWriteLine(out, cliRenderMeta(" step "+step.ID+":", detail)); err != nil {
967+ return err
968+ }
969+ for _, diagnostic := range step.Diagnostics {
970+ if err := cliWriteLine(out, cliRenderMuted(" "+sanitizeTerminalText(diagnostic))); err != nil {
971+ return err
972+ }
973+ }
974+ }
975+ return nil
976+}
977+
978+func sanitizeTerminalText(value string) string {
979+ return strings.Map(func(r rune) rune {
980+ if r == '\n' || r == '\t' || r >= ' ' && r != '\x7f' {
981+ return r
982+ }
983+ return '�'
984+ }, value)
985+}
986+
987+func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
988+ headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
989+ if resp.Outcome == "success" {
990+ return cliWriteLine(out, cliRenderSuccess(headline))
991+ }
992+ return cliWriteLine(out, cliRenderWarning(headline))
993+}
994+
995+func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
996+ names := make([]string, 0, len(resp.Services))
997+ for name := range resp.Services {
998+ names = append(names, name)
999+ }
1000+ sort.Strings(names)
1001+ for _, name := range names {
1002+ svc := resp.Services[name]
1003+ detail := svc.Result + " " + svc.EffectiveRevision
1004+ if svc.Result == domain.AppServiceUnchanged {
1005+ detail += " (already running this image, config, and secrets)"
1006+ }
1007+ if svc.RestartUnsafe {
1008+ detail += " restart_unsafe"
1009+ }
1010+ if svc.Error != "" {
1011+ detail += ": " + svc.Error
1012+ }
1013+ if err := cliWriteLine(out, cliRenderMeta(" "+name+":", detail)); err != nil {
1014+ return err
1015+ }
1016+ }
1017+ for _, w := range resp.CleanupWarnings {
1018+ if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("cleanup: %s left %s (%s)", w.Service, w.Leftover, w.Detail))); err != nil {
1019+ return err
1020+ }
1021+ }
1022+ return nil
1023+}
1024+
1025+func renderDeploySummaries(out io.Writer, resp *dto.AppDeployResponse) error {
1026+ if resp.Effective != nil && len(resp.Effective.Services) > 0 {
1027+ pairs := make([]string, 0, len(resp.Effective.Services))
1028+ for name, rev := range resp.Effective.Services {
1029+ pairs = append(pairs, name+"="+rev)
1030+ }
1031+ sort.Strings(pairs)
1032+ if err := cliWriteLine(out, cliRenderMeta("effective:", strings.Join(pairs, " "))); err != nil {
1033+ return err
1034+ }
1035+ }
1036+ if resp.Retained == nil {
1037+ return nil
1038+ }
1039+ owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
1040+ owned = append(owned, resp.Retained.Secrets...)
1041+ if len(owned) > 0 {
1042+ sort.Strings(owned)
1043+ if err := cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))); err != nil {
1044+ return err
1045+ }
1046+ }
1047+ return nil
1048+}
1049+
1050+// newAppStatusCmd creates `status APP`: effective vs observed, per service.
1051+func newAppStatusCmd() *cobra.Command {
1052+ var jsonOut bool
1053+ cmd := &cobra.Command{
1054+ Use: "status APP",
1055+ Short: "Show effective vs observed state for an app",
1056+ Args: cobra.ExactArgs(1),
1057+ RunE: func(cmd *cobra.Command, args []string) error {
1058+ handle, err := resolveAppPlane()
1059+ if err != nil {
1060+ return err
1061+ }
1062+ defer handle.close()
1063+ plane := handle.plane
1064+ return runAppStatus(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
1065+ },
1066+ }
1067+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1068+ return cmd
1069+}
1070+
1071+func runAppStatus(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
1072+ resp, err := plane.ShowApp(ctx, app)
1073+ if err != nil {
1074+ return err
1075+ }
1076+ if jsonOut {
1077+ return writeJSON(out, resp)
1078+ }
1079+ if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
1080+ return err
1081+ }
1082+ for _, line := range renderActiveServices(resp.Active) {
1083+ if err := cliWriteLine(out, line); err != nil {
1084+ return err
1085+ }
1086+ }
1087+ var running []string
1088+ for _, svc := range resp.Active.Services {
1089+ if svc.Container != "" {
1090+ running = append(running, svc.Container)
1091+ }
1092+ }
1093+ sort.Strings(running)
1094+ observed := "none"
1095+ if len(running) > 0 {
1096+ observed = strings.Join(running, " ")
1097+ }
1098+ if err := cliWriteLine(out, cliRenderMeta("observed:", observed)); err != nil {
1099+ return err
1100+ }
1101+ if resp.Intent.Stopped {
1102+ return cliWriteLine(out, cliRenderWarning("stopped intent is set"))
1103+ }
1104+ return nil
1105+}
1106+
1107+// appLogReader streams logs by Gordon app/service reference. The daemon
1108+// resolves the reference through its authoritative ACTIVE record.
1109+type appLogReader interface {
1110+ GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error)
1111+ StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error)
1112+}
1113+
1114+// remoteAppLogReader adapts *remote.Client to appLogReader.
1115+type remoteAppLogReader struct {
1116+ client *remote.Client
1117+}
1118+
1119+func (r *remoteAppLogReader) GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) {
1120+ return r.client.GetContainerLogs(ctx, ref, lines)
1121+}
1122+
1123+func (r *remoteAppLogReader) StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) {
1124+ return r.client.StreamContainerLogs(ctx, ref, lines)
1125+}
1126+
1127+// newAppLogsCmd creates `logs APP`.
1128+func newAppLogsCmd() *cobra.Command {
1129+ var service string
1130+ var follow bool
1131+ var tail int
1132+ var jsonOut bool
1133+ cmd := &cobra.Command{
1134+ Use: "logs APP",
1135+ Short: "Show logs for an app service",
1136+ Args: cobra.ExactArgs(1),
1137+ RunE: func(cmd *cobra.Command, args []string) error {
1138+ client, err := resolveAppClient()
1139+ if err != nil {
1140+ return err
1141+ }
1142+ return runAppLogs(cmd.Context(), client, &remoteAppLogReader{client: client}, args[0], service, follow, tail, cmd.OutOrStdout(), jsonOut)
1143+ },
1144+ }
1145+ cmd.Flags().StringVar(&service, "service", "", "Service to read logs from (required when the app has several)")
1146+ cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
1147+ cmd.Flags().IntVarP(&tail, "tail", "n", 50, "Number of lines to show")
1148+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1149+ return cmd
1150+}
1151+
1152+func runAppLogs(ctx context.Context, plane ControlPlane, reader appLogReader, app, service string, follow bool, tail int, out io.Writer, jsonOut bool) error {
1153+ if follow && jsonOut {
1154+ return fmt.Errorf("cannot combine --json with --follow: follow streams plain log lines")
1155+ }
1156+ show, err := plane.ShowApp(ctx, app)
1157+ if err != nil {
1158+ return err
1159+ }
1160+ ref, err := appLogRef(show, service)
1161+ if err != nil {
1162+ return err
1163+ }
1164+ if follow {
1165+ stream, err := reader.StreamContainerLogs(ctx, ref, tail)
1166+ if err != nil {
1167+ return err
1168+ }
1169+ for line := range stream {
1170+ if err := cliWriteLine(out, line); err != nil {
1171+ return err
1172+ }
1173+ }
1174+ return nil
1175+ }
1176+ lines, err := reader.GetContainerLogs(ctx, ref, tail)
1177+ if err != nil {
1178+ return err
1179+ }
1180+ if jsonOut {
1181+ return writeJSON(out, map[string]any{"app": app, "service": service, "lines": lines})
1182+ }
1183+ for _, line := range lines {
1184+ if err := cliWriteLine(out, line); err != nil {
1185+ return err
1186+ }
1187+ }
1188+ return nil
1189+}
1190+
1191+// appLogRef resolves the container ref for one app service. When service
1192+// is empty and the app has exactly one service, that service is used;
1193+// otherwise --service is required. A service with no recorded container
1194+// has nothing to stream yet.
1195+func appLogRef(show *dto.AppShowResponse, service string) (string, error) {
1196+ if len(show.Active.Services) == 0 {
1197+ return "", fmt.Errorf("app %s has no active services yet", show.App)
1198+ }
1199+ if service == "" {
1200+ if len(show.Active.Services) == 1 {
1201+ for name := range show.Active.Services {
1202+ service = name
1203+ }
1204+ } else {
1205+ names := make([]string, 0, len(show.Active.Services))
1206+ for name := range show.Active.Services {
1207+ names = append(names, name)
1208+ }
1209+ sort.Strings(names)
1210+ return "", fmt.Errorf("app %s has several services (%s): pass --service", show.App, strings.Join(names, ", "))
1211+ }
1212+ }
1213+ svc, ok := show.Active.Services[service]
1214+ if !ok {
1215+ return "", fmt.Errorf("app %s has no service %q", show.App, service)
1216+ }
1217+ if svc.Container == "" {
1218+ return "", fmt.Errorf("service %s has no recorded container yet", service)
1219+ }
1220+ return show.App + "/" + service, nil
1221+}
A internal/adapters/in/cli/apps_apply_watch_test.go +187 -0
@@ -0,0 +1,187 @@
1+package cli
2+
3+// Tests for the `apps apply --deploy` chain over a 202 accepted deploy:
4+// it must reuse the shared by-key watch (progress only on change, terminal
5+// journal, Ctrl-C resume) and must never reissue the deploy POST. JSON mode
6+// keeps stdout to exactly one final combined {apply,deploy} document while
7+// progress and resume guidance go to stderr.
8+
9+import (
10+ "bytes"
11+ "context"
12+ "encoding/json"
13+ "io"
14+ "strings"
15+ "testing"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/internal/adapters/dto"
22+)
23+
24+func acceptedApply() *dto.AppApplyResponse {
25+ return &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}
26+}
27+
28+func TestRunAppsApply_DeployAcceptedPollsToSuccess(t *testing.T) {
29+ shortenOperationPoll(t)
30+ plane := appPlane(t)
31+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
32+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
33+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
34+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
35+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
36+
37+ var out, errOut bytes.Buffer
38+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
39+ writeManifest(t, "[app]\nname = \"blog\"\n"), false, true, false))
40+ text := out.String()
41+ assert.Contains(t, text, "Applied blog (rev-b)", "the apply outcome stays visible")
42+ assert.Contains(t, text, "op op-1:", "the accepted deploy reports progress through the watch path")
43+ assert.Contains(t, text, "op-1 blog: success", "the terminal journal is rendered")
44+}
45+
46+func TestRunAppsApply_DeployAcceptedNoSecondMutation(t *testing.T) {
47+ shortenOperationPoll(t)
48+ plane := appPlane(t)
49+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
50+ // .Once() proves the accepted deploy is observed by key, never re-POSTed.
51+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
52+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
53+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
54+
55+ var out, errOut bytes.Buffer
56+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
57+ writeManifest(t, "x"), false, true, false))
58+}
59+
60+func TestRunAppsApply_DeployAcceptedUnchangedStateNotReprinted(t *testing.T) {
61+ shortenOperationPoll(t)
62+ plane := appPlane(t)
63+ step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
64+ advanced := runningOp("op-1",
65+ dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
66+ dto.AppStepDTO{ID: "service.web.health", State: "pending"})
67+
68+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
69+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
70+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(runningOp("op-1", step), nil).Once()
71+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
72+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
73+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
74+
75+ var out, errOut bytes.Buffer
76+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
77+ writeManifest(t, "x"), false, true, false))
78+ assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
79+ "initial and one advanced poll render progress; the unchanged poll is not reprinted")
80+}
81+
82+func TestRunAppsApply_DeployAcceptedPartialFailsButKeepsApply(t *testing.T) {
83+ shortenOperationPoll(t)
84+ plane := appPlane(t)
85+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
86+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
87+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
88+ Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
89+ Services: map[string]dto.AppServiceResultDTO{
90+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
91+ },
92+ }, nil).Once()
93+
94+ var out, errOut bytes.Buffer
95+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
96+ writeManifest(t, "x"), false, true, false)
97+ require.Error(t, err, "a terminal partial deploy must exit nonzero")
98+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
99+ assert.Contains(t, err.Error(), "partial")
100+ assert.Contains(t, out.String(), "Applied blog (rev-b)", "the apply outcome stays visible")
101+ assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
102+}
103+
104+func TestRunAppsApply_DeployAcceptedJSONKeepsStdoutSingleCombinedDocument(t *testing.T) {
105+ shortenOperationPoll(t)
106+ plane := appPlane(t)
107+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
108+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
109+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
110+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
111+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
112+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
113+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
114+
115+ var out, errOut bytes.Buffer
116+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
117+ writeManifest(t, "x"), false, true, true))
118+
119+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
120+ var got appApplyDeployDocument
121+ require.NoError(t, dec.Decode(&got))
122+ assert.Equal(t, "apply-1", got.Apply.Intent)
123+ assert.Equal(t, "op-1", got.Deploy.Op)
124+ assert.Equal(t, "success", got.Deploy.Outcome)
125+ var extra json.RawMessage
126+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
127+ assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
128+ assert.NotContains(t, out.String(), dto.AppStatusRunning, "no initial running JSON is emitted")
129+ assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130+}
131+
132+func TestRunAppsApply_DeployAcceptedJSONFailureKeepsApplyDocument(t *testing.T) {
133+ shortenOperationPoll(t)
134+ plane := appPlane(t)
135+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
136+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
137+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
138+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "failed", Outcome: "failed"}, nil).Once()
139+
140+ var out, errOut bytes.Buffer
141+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
142+ writeManifest(t, "x"), false, true, true)
143+ require.Error(t, err)
144+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
145+
146+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
147+ var got appApplyDeployDocument
148+ require.NoError(t, dec.Decode(&got))
149+ assert.Equal(t, "rev-b", got.Apply.ResultingRevision, "the failed deploy must not hide the successful apply")
150+ assert.Equal(t, "failed", got.Deploy.Outcome)
151+ var extra json.RawMessage
152+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
153+}
154+
155+func TestRunAppsApply_DeployAcceptedCancelPrintsResume(t *testing.T) {
156+ plane := appPlane(t)
157+ ctx, cancel := context.WithCancel(context.Background())
158+ cancel()
159+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
160+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
161+
162+ var out, errOut bytes.Buffer
163+ err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
164+ writeManifest(t, "x"), false, true, false)
165+ require.Error(t, err)
166+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
167+ text := out.String()
168+ assert.Contains(t, text, "not cancelled", "Ctrl-C must not imply a server-side abort")
169+ assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
170+}
171+
172+func TestRunAppsApply_DeployAcceptedCancelKeepsJSONStdoutClean(t *testing.T) {
173+ plane := appPlane(t)
174+ ctx, cancel := context.WithCancel(context.Background())
175+ cancel()
176+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
177+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
178+
179+ var out, errOut bytes.Buffer
180+ err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
181+ writeManifest(t, "x"), false, true, true)
182+ require.Error(t, err)
183+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
184+ assert.Empty(t, out.String(), "JSON stdout stays empty when interrupted before terminal")
185+ assert.Contains(t, errOut.String(), "not cancelled")
186+ assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
187+}
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, &bytes.Buffer{},
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, &bytes.Buffer{},
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 +155 -0
@@ -0,0 +1,155 @@
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+[services.web]
30+image = "img:1"
31+[[services.web.http]]
32+host = "blog.example.com"
33+port = 8080
34+`
35+
36+// TestSharedCommandLocalAdminComposition_Status executes a shared command
37+// through socket discovery, the Unix transport, and the local authority.
38+func TestSharedCommandLocalAdminComposition_Status(t *testing.T) {
39+ xdg := t.TempDir()
40+ t.Setenv("XDG_RUNTIME_DIR", xdg)
41+ withRemoteTarget(t, "")
42+
43+ configSvc := inmocks.NewMockConfigService(t)
44+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.test").Once()
45+ configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
46+ configSvc.EXPECT().GetServerPort().Return(8080).Once()
47+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true).Once()
48+ appSvc := inmocks.NewMockAppService(t)
49+ appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{App: "blog", Active: "rev-1", Converged: true}}, nil).Once()
50+
51+ handler := adminhttp.NewHandler(adminhttp.HandlerDeps{ConfigSvc: configSvc, AppSvc: appSvc, Log: zerowrap.Default()})
52+ shutdown := startLocalAuthority(t, filepath.Join(xdg, "gordon"), handler.LocalAuthority())
53+ defer shutdown()
54+ stubLocalAppClient(t, remote.NewLocalClient)
55+
56+ cmd := newStatusCmd()
57+ var out bytes.Buffer
58+ cmd.SetOut(&out)
59+ require.NoError(t, cmd.ExecuteContext(context.Background()))
60+ assert.Contains(t, out.String(), "registry.example.test")
61+ assert.Contains(t, out.String(), "blog")
62+}
63+
64+// TestAppLocalAdminComposition_EndToEnd composes the real local authority,
65+// Unix listener, discovery, transport, and app control plane.
66+func TestAppLocalAdminComposition_EndToEnd(t *testing.T) {
67+ xdg := t.TempDir()
68+ t.Setenv("XDG_RUNTIME_DIR", xdg)
69+ withRemoteTarget(t, "")
70+
71+ appSvc := inmocks.NewMockAppService(t)
72+ appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
73+ &in.AppApplyResult{App: "blog", ResultingRevision: "rev-2", Pending: true, IntentID: "apply-1"},
74+ nil, nil,
75+ ).Once()
76+ appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{
77+ {App: "blog", Desired: "rev-2", Active: "rev-1"},
78+ }, nil).Once()
79+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
80+ App: "blog",
81+ DesiredRevision: "rev-2",
82+ DesiredStatus: "pending",
83+ Services: map[string]in.AppServiceView{"web": {Container: "ctr-web"}},
84+ }, nil).Once()
85+ appSvc.EXPECT().Diff(mock.Anything, "blog").Return(domain.AppDiff{}, nil).Once()
86+ appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&domain.AppOperation{
87+ Op: "apply",
88+ App: "blog",
89+ Outcome: "succeeded",
90+ }, nil).Once()
91+
92+ handler := adminhttp.NewHandler(adminhttp.HandlerDeps{AppSvc: appSvc, Log: zerowrap.Default()})
93+ dir := filepath.Join(xdg, "gordon")
94+ shutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
95+
96+ // The CLI resolves the socket through real discovery and the Unix transport.
97+ stubLocalAppClient(t, remote.NewLocalClient)
98+
99+ handle, err := resolveAppPlane()
100+ require.NoError(t, err)
101+ defer handle.close()
102+ plane := handle.plane
103+
104+ ctx := context.Background()
105+
106+ apply, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: validLocalAppManifest})
107+ require.NoError(t, err)
108+ assert.Equal(t, "rev-2", apply.ResultingRevision)
109+
110+ apps, err := plane.ListApps(ctx)
111+ require.NoError(t, err)
112+ require.Len(t, apps, 1)
113+ assert.Equal(t, "blog", apps[0].App)
114+
115+ show, err := plane.ShowApp(ctx, "blog")
116+ require.NoError(t, err)
117+ assert.Equal(t, "ctr-web", show.Active.Services["web"].Container)
118+
119+ diff, err := plane.DiffApp(ctx, "blog")
120+ require.NoError(t, err)
121+ assert.Equal(t, "blog", diff.App)
122+
123+ // Idempotent-mutation recovery path: re-query the same key.
124+ op, err := plane.OperationByKey(ctx, "blog", "key-1")
125+ require.NoError(t, err)
126+ assert.Equal(t, "succeeded", op.Outcome)
127+
128+ // Shutdown removes only the daemon-owned socket; restart recreates it.
129+ shutdown()
130+ assert.NoFileExists(t, localadmin.SocketPath(dir))
131+
132+ restartShutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
133+ require.FileExists(t, localadmin.SocketPath(dir))
134+ restartShutdown()
135+ assert.NoFileExists(t, localadmin.SocketPath(dir))
136+}
137+
138+// startLocalAuthority serves the given handler on an owner-only Unix socket
139+// and returns a shutdown function that stops the server and removes the
140+// socket, mirroring daemon lifecycle.
141+func startLocalAuthority(t *testing.T, dir string, handler http.Handler) func() {
142+ t.Helper()
143+ ln, info, err := localadmin.Listen(dir)
144+ require.NoError(t, err)
145+
146+ srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
147+ go func() { _ = srv.Serve(ln) }()
148+
149+ return func() {
150+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
151+ defer cancel()
152+ _ = srv.Shutdown(ctx)
153+ require.NoError(t, localadmin.RemoveOwnedSocket(localadmin.SocketPath(dir), info))
154+ }
155+}
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 := remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
139+ remotePlane := 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_operations.go +51 -0
@@ -0,0 +1,51 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+func newAppsOperationsCmd() *cobra.Command {
12+ cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"}
13+ cmd.AddCommand(newAppsOperationsShowCmd(), newAppsOperationsWatchCmd())
14+ return cmd
15+}
16+
17+func newAppsOperationsShowCmd() *cobra.Command {
18+ var key string
19+ var jsonOut bool
20+ cmd := &cobra.Command{
21+ Use: "show APP",
22+ Short: "Show an app operation by request key",
23+ Args: cobra.ExactArgs(1),
24+ RunE: func(cmd *cobra.Command, args []string) error {
25+ handle, err := resolveAppPlane()
26+ if err != nil {
27+ return err
28+ }
29+ defer handle.close()
30+ return runAppsOperationsShow(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], key, jsonOut)
31+ },
32+ }
33+ cmd.Flags().StringVar(&key, "key", "", "Request key (required)")
34+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
35+ _ = cmd.MarkFlagRequired("key")
36+ return cmd
37+}
38+
39+func runAppsOperationsShow(ctx context.Context, plane ControlPlane, out io.Writer, app, key string, jsonOut bool) error {
40+ if app == "" || key == "" {
41+ return fmt.Errorf("APP and --key are required")
42+ }
43+ operation, err := plane.OperationByKey(ctx, app, key)
44+ if err != nil {
45+ return fmt.Errorf("lookup operation for app %s: %w", app, err)
46+ }
47+ if jsonOut {
48+ return writeJSON(out, operation)
49+ }
50+ return renderAppDeployResponse(out, operation)
51+}
A internal/adapters/in/cli/apps_operations_watch.go +46 -0
@@ -0,0 +1,46 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+// newAppsOperationsWatchCmd creates `operations watch APP --key KEY`: it
12+// resumes observing an existing operation through the by-key endpoint until
13+// it is terminal, never reissuing the mutation.
14+func newAppsOperationsWatchCmd() *cobra.Command {
15+ var key string
16+ var jsonOut bool
17+ cmd := &cobra.Command{
18+ Use: "watch APP",
19+ Short: "Poll an app operation until it is terminal",
20+ Long: `Polls the by-key operation journal until the operation is terminal.
21+
22+Use it to resume watching an operation from an earlier interrupted run
23+without reissuing the mutation. Ctrl-C stops only local polling; the
24+daemon-side operation keeps running and the command prints how to resume.`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ handle, err := resolveAppPlane()
28+ if err != nil {
29+ return err
30+ }
31+ defer handle.close()
32+ return runAppsOperationsWatch(cmd.Context(), handle.plane, cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], key, jsonOut)
33+ },
34+ }
35+ cmd.Flags().StringVar(&key, "key", "", "Request key (required)")
36+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
37+ _ = cmd.MarkFlagRequired("key")
38+ return cmd
39+}
40+
41+func runAppsOperationsWatch(ctx context.Context, plane ControlPlane, out, errOut io.Writer, app, key string, jsonOut bool) error {
42+ if app == "" || key == "" {
43+ return fmt.Errorf("APP and --key are required")
44+ }
45+ return watchOperation(ctx, plane, app, key, nil, out, errOut, jsonOut)
46+}
A internal/adapters/in/cli/apps_test.go +549 -0
@@ -0,0 +1,549 @@
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{}, &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{}, &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, &bytes.Buffer{},
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, &bytes.Buffer{},
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, &bytes.Buffer{},
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\nspaced= preserved \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+ "spaced": " preserved ",
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", dto.AppDeployRequest{}, &bytes.Buffer{}, &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", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, 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", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, 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", "", false).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", "", false, &out, false))
449+ assert.Contains(t, out.String(), "success")
450+
451+ out.Reset()
452+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &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+}
532+
533+func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) {
534+ plane := appPlane(t)
535+ plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{
536+ {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
537+ }, nil).Once()
538+ var out bytes.Buffer
539+ require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false))
540+ assert.Contains(t, out.String(), "web/DATABASE_URL")
541+ assert.Contains(t, out.String(), "desired")
542+
543+ errPlane := appPlane(t)
544+ errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once()
545+ err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false)
546+ require.Error(t, err)
547+ assert.Contains(t, err.Error(), "list app secrets for blog")
548+ assert.Contains(t, err.Error(), "boom")
549+}
A internal/adapters/in/cli/apps_watch.go +225 -0
@@ -0,0 +1,225 @@
1+package cli
2+
3+// Operation watch: a 202 mutation (or an explicit resume) is observed
4+// through the existing by-key journal endpoint until it reaches a terminal
5+// state. Polling is intentionally local: the CLI never reissues the
6+// mutation and never asks the daemon to cancel the operation, so Ctrl-C
7+// only stops this process from watching.
8+
9+import (
10+ "context"
11+ "fmt"
12+ "io"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/gordon/internal/adapters/dto"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+// operationPollInterval is the pause between by-key polls of a running
21+// operation. It is a package variable (no flag) so command tests can
22+// shorten it; production uses a modest one-second cadence.
23+var operationPollInterval = time.Second
24+
25+// operationPollMaxTransientErrors bounds consecutive transient polling
26+// failures so a broken connection fails loudly instead of spinning or
27+// hiding the error.
28+const operationPollMaxTransientErrors = 5
29+
30+// OperationFailedError reports a terminal non-success watch outcome. The
31+// terminal journal is rendered before this error is returned so the
32+// command exits nonzero without hiding the terminal detail.
33+type OperationFailedError struct {
34+ App string
35+ Op string
36+ Outcome string
37+}
38+
39+func (e *OperationFailedError) Error() string {
40+ return fmt.Sprintf("operation %s for app %s did not succeed (outcome %s)", e.Op, e.App, e.Outcome)
41+}
42+
43+// operationResumeCommand is the concise command that resumes polling an
44+// existing operation without reissuing the mutation.
45+func operationResumeCommand(app, key string) string {
46+ cmd := fmt.Sprintf("gordon apps operations watch %s --key %s", app, key)
47+ if remoteFlag != "" {
48+ cmd += " --remote " + remoteFlag
49+ }
50+ return cmd
51+}
52+
53+// watchOperation polls the by-key journal until the operation is terminal,
54+// reports progress while it runs, then renders the terminal journal. In
55+// JSON mode stdout carries exactly one final document and progress goes to
56+// errOut. A terminal non-success outcome renders the journal and returns
57+// OperationFailedError.
58+func watchOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, out, errOut io.Writer, jsonOut bool) error {
59+ if app == "" || key == "" {
60+ return fmt.Errorf("APP and --key are required")
61+ }
62+ progressOut := out
63+ if jsonOut {
64+ progressOut = errOut
65+ }
66+ op, err := awaitOperation(ctx, plane, app, key, initial, progressOut)
67+ if err != nil {
68+ return err
69+ }
70+ if jsonOut {
71+ if err := writeJSON(out, op); err != nil {
72+ return err
73+ }
74+ } else if err := renderAppDeployResponse(out, op); err != nil {
75+ return err
76+ }
77+ if op.Outcome == domain.AppOutcomeSuccess {
78+ return nil
79+ }
80+ return &OperationFailedError{App: app, Op: op.Op, Outcome: op.Outcome}
81+}
82+
83+// awaitOperation polls OperationByKey until the journal is terminal. The
84+// optional initial response (the 202 mutation body) is reported before the
85+// first poll so its state is not lost. Progress is written only when the
86+// operation/step fingerprint changes.
87+func awaitOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, progressOut io.Writer) (*dto.AppDeployResponse, error) {
88+ lastProgress := ""
89+ transient := 0
90+ for {
91+ op := initial
92+ initial = nil
93+ if op == nil {
94+ got, err := plane.OperationByKey(ctx, app, key)
95+ if err != nil {
96+ if perr := handlePollError(ctx, progressOut, app, key, err, &transient); perr != nil {
97+ return nil, perr
98+ }
99+ continue
100+ }
101+ transient = 0
102+ op = got
103+ }
104+ if op.Status != dto.AppStatusRunning {
105+ return op, nil
106+ }
107+ if sig := operationProgressSignature(op); sig != lastProgress {
108+ lastProgress = sig
109+ if err := renderOperationProgress(progressOut, op); err != nil {
110+ return nil, err
111+ }
112+ }
113+ if err := waitOperationPoll(ctx); err != nil {
114+ return nil, stopOperationWatch(progressOut, app, key, err)
115+ }
116+ }
117+}
118+
119+// handlePollError classifies one by-key lookup error. It returns nil when
120+// the caller should poll again and a terminal error otherwise: 404 is
121+// terminal, cancellation stops local polling, and any other failure is
122+// retried up to operationPollMaxTransientErrors consecutive times.
123+func handlePollError(ctx context.Context, progressOut io.Writer, app, key string, err error, transient *int) error {
124+ if ctxErr := ctx.Err(); ctxErr != nil {
125+ return stopOperationWatch(progressOut, app, key, ctxErr)
126+ }
127+ if isRemoteNotFoundError(err) {
128+ return fmt.Errorf("operation %s for app %s is no longer recorded; nothing to resume: %w", key, app, err)
129+ }
130+ *transient++
131+ if *transient > operationPollMaxTransientErrors {
132+ return fmt.Errorf(
133+ "gave up watching operation %s for app %s after %d consecutive polling errors: %w; resume with %s",
134+ key, app, *transient, err, operationResumeCommand(app, key))
135+ }
136+ if werr := cliWriteLine(progressOut, cliRenderWarning(fmt.Sprintf(
137+ "operation watch: transient polling error (%d/%d): %v", *transient, operationPollMaxTransientErrors, err))); werr != nil {
138+ return werr
139+ }
140+ if werr := waitOperationPoll(ctx); werr != nil {
141+ return stopOperationWatch(progressOut, app, key, werr)
142+ }
143+ return nil
144+}
145+
146+// waitOperationPoll sleeps for one polling interval, aborting early when
147+// the command context is cancelled.
148+func waitOperationPoll(ctx context.Context) error {
149+ if err := ctx.Err(); err != nil {
150+ return err
151+ }
152+ timer := time.NewTimer(operationPollInterval)
153+ defer timer.Stop()
154+ select {
155+ case <-ctx.Done():
156+ return ctx.Err()
157+ case <-timer.C:
158+ return nil
159+ }
160+}
161+
162+// stopOperationWatch reports an interrupted watch without implying any
163+// server-side cancellation, then returns the interruption as an error. The
164+// resume command is printed so the user can continue watching later.
165+func stopOperationWatch(progressOut io.Writer, app, key string, cause error) error {
166+ if err := cliWriteLine(progressOut, cliRenderWarning("polling stopped; the operation is not cancelled and keeps running on the daemon")); err != nil {
167+ return err
168+ }
169+ if err := cliWriteLine(progressOut, cliRenderMeta("resume:", operationResumeCommand(app, key))); err != nil {
170+ return err
171+ }
172+ return fmt.Errorf("operation watch interrupted: %w", cause)
173+}
174+
175+// operationProgressSignature fingerprints the visible operation/step state
176+// so unchanged polls are not reprinted.
177+func operationProgressSignature(op *dto.AppDeployResponse) string {
178+ var b strings.Builder
179+ b.WriteString(op.Status)
180+ b.WriteByte(0)
181+ b.WriteString(op.Outcome)
182+ for _, step := range op.Steps {
183+ b.WriteByte(0)
184+ b.WriteString(step.ID)
185+ b.WriteByte('=')
186+ b.WriteString(step.State)
187+ b.WriteByte(':')
188+ b.WriteString(step.Detail)
189+ b.WriteByte(':')
190+ b.WriteString(step.Error)
191+ }
192+ return b.String()
193+}
194+
195+// renderOperationProgress writes one concise progress line for the current
196+// operation/step state.
197+func renderOperationProgress(out io.Writer, op *dto.AppDeployResponse) error {
198+ detail := op.Status
199+ if step, ok := activeOperationStep(op.Steps); ok {
200+ detail = fmt.Sprintf("%s %s: %s", op.Status, step.ID, step.State)
201+ if step.Error != "" {
202+ detail += ": " + sanitizeTerminalText(step.Error)
203+ } else if step.Detail != "" {
204+ detail += ": " + sanitizeTerminalText(step.Detail)
205+ }
206+ }
207+ return cliWriteLine(out, cliRenderMeta("op "+op.Op+":", detail))
208+}
209+
210+// activeOperationStep returns the step still to run (pending) when one
211+// exists, otherwise the last journaled step.
212+func activeOperationStep(steps []dto.AppStepDTO) (dto.AppStepDTO, bool) {
213+ if len(steps) == 0 {
214+ return dto.AppStepDTO{}, false
215+ }
216+ for _, step := range steps {
217+ switch step.State {
218+ case domain.AppStepSucceeded, domain.AppStepFailed, domain.AppStepNotRun:
219+ continue
220+ default:
221+ return step, true
222+ }
223+ }
224+ return steps[len(steps)-1], true
225+}
A internal/adapters/in/cli/apps_watch_test.go +208 -0
@@ -0,0 +1,208 @@
1+package cli
2+
3+// Tests for 202 deploy polling and `apps operations watch`: state-change
4+// progress, machine-readable JSON, terminal rendering/exit, bounded
5+// transient failures, fast 404, and Ctrl-C resume without server abort.
6+
7+import (
8+ "bytes"
9+ "context"
10+ "encoding/json"
11+ "errors"
12+ "io"
13+ "net/http"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "github.com/bnema/gordon/internal/adapters/dto"
23+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
24+)
25+
26+// shortenOperationPoll makes the polling cadence near-instant for tests.
27+func shortenOperationPoll(t *testing.T) {
28+ t.Helper()
29+ prev := operationPollInterval
30+ operationPollInterval = time.Millisecond
31+ t.Cleanup(func() { operationPollInterval = prev })
32+}
33+
34+func runningOp(op string, steps ...dto.AppStepDTO) *dto.AppDeployResponse {
35+ return &dto.AppDeployResponse{Op: op, App: "blog", Status: dto.AppStatusRunning, Steps: steps}
36+}
37+
38+func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
39+ shortenOperationPoll(t)
40+ plane := appPlane(t)
41+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
42+ runningOp("op-1"), "key-1", nil).Once()
43+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
44+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
45+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
46+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
47+
48+ var out, errOut bytes.Buffer
49+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
50+ text := out.String()
51+ assert.Contains(t, text, "op op-1:")
52+ assert.Contains(t, text, "running")
53+ assert.Contains(t, text, "success")
54+}
55+
56+func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
57+ shortenOperationPoll(t)
58+ plane := appPlane(t)
59+ step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
60+ unchanged := runningOp("op-1", step)
61+ advanced := runningOp("op-1",
62+ dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
63+ dto.AppStepDTO{ID: "service.web.health", State: "pending"})
64+
65+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
66+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(unchanged, nil).Once()
67+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
68+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
69+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
70+
71+ var out, errOut bytes.Buffer
72+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
73+ assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
74+ "initial and one advanced poll render progress; the unchanged poll is not reprinted")
75+}
76+
77+func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
78+ shortenOperationPoll(t)
79+ plane := appPlane(t)
80+ // .Once() on DeployApp proves the accepted path never re-POSTs; the
81+ // by-key lookups are the only way the outcome is observed.
82+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
83+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
84+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
85+
86+ var out, errOut bytes.Buffer
87+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
88+}
89+
90+func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
91+ shortenOperationPoll(t)
92+ plane := appPlane(t)
93+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
94+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
95+ Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
96+ Services: map[string]dto.AppServiceResultDTO{
97+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
98+ },
99+ }, nil).Once()
100+
101+ var out, errOut bytes.Buffer
102+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false)
103+ require.Error(t, err, "a terminal partial outcome must exit nonzero")
104+ assert.Contains(t, err.Error(), "partial")
105+ assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
106+}
107+
108+func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
109+ shortenOperationPoll(t)
110+ plane := appPlane(t)
111+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
112+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
113+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
114+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
115+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
116+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117+
118+ var out, errOut bytes.Buffer
119+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, true))
120+
121+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122+ var got dto.AppDeployResponse
123+ require.NoError(t, dec.Decode(&got))
124+ assert.Equal(t, "op-1", got.Op)
125+ assert.Equal(t, "success", got.Outcome)
126+ var extra json.RawMessage
127+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
128+ assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
129+ assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130+}
131+
132+func TestWatchOperation_CancelPrintsResumeWithoutAbort(t *testing.T) {
133+ plane := appPlane(t)
134+ ctx, cancel := context.WithCancel(context.Background())
135+ cancel()
136+
137+ var out, errOut bytes.Buffer
138+ err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, false)
139+ require.Error(t, err)
140+ assert.ErrorIs(t, err, context.Canceled)
141+ text := out.String()
142+ assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
143+ assert.Contains(t, text, "not cancelled")
144+ assert.NotContains(t, text, "cancel op")
145+}
146+
147+func TestWatchOperation_CancelKeepsJSONStdoutClean(t *testing.T) {
148+ plane := appPlane(t)
149+ ctx, cancel := context.WithCancel(context.Background())
150+ cancel()
151+
152+ var out, errOut bytes.Buffer
153+ err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, true)
154+ require.Error(t, err)
155+ assert.Empty(t, out.String(), "JSON stdout stays empty on interruption")
156+ assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
157+}
158+
159+func TestRunAppsOperationsWatch_ResumesExisting(t *testing.T) {
160+ shortenOperationPoll(t)
161+ plane := appPlane(t)
162+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
163+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
164+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
165+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
166+
167+ var out, errOut bytes.Buffer
168+ require.NoError(t, runAppsOperationsWatch(context.Background(), plane, &out, &errOut, "blog", "key-1", false))
169+ assert.Contains(t, out.String(), "success")
170+}
171+
172+func TestAwaitOperation_TransientErrorsAreBounded(t *testing.T) {
173+ shortenOperationPoll(t)
174+ plane := appPlane(t)
175+ boom := errors.New("connection refused")
176+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(nil, boom).Times(operationPollMaxTransientErrors + 1)
177+
178+ var progress bytes.Buffer
179+ _, err := awaitOperation(context.Background(), plane, "blog", "key-1", nil, &progress)
180+ require.Error(t, err)
181+ assert.Contains(t, err.Error(), "gave up watching")
182+ assert.Contains(t, err.Error(), "gordon apps operations watch blog --key key-1")
183+ assert.Contains(t, progress.String(), "transient polling error")
184+}
185+
186+func TestAwaitOperation_NotFoundFailsFast(t *testing.T) {
187+ plane := appPlane(t)
188+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "gone").Return(nil,
189+ &remote.HTTPError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Body: "unknown operation"}).Once()
190+
191+ _, err := awaitOperation(context.Background(), plane, "blog", "gone", nil, &bytes.Buffer{})
192+ require.Error(t, err)
193+ assert.Contains(t, err.Error(), "no longer recorded")
194+}
195+
196+func TestAppsOperationsWatchCmd_Flags(t *testing.T) {
197+ cmd := newAppsOperationsWatchCmd()
198+ assert.Equal(t, "watch APP", cmd.Use)
199+ assert.NotNil(t, cmd.Flags().Lookup("key"))
200+ assert.NotNil(t, cmd.Flags().Lookup("json"))
201+
202+ subs := map[string]bool{}
203+ for _, sub := range newAppsOperationsCmd().Commands() {
204+ subs[sub.Name()] = true
205+ }
206+ assert.True(t, subs["show"])
207+ assert.True(t, subs["watch"])
208+}
D internal/adapters/in/cli/attachments.go +0 -516
@@ -1,516 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8-
9- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12- "github.com/bnema/gordon/internal/domain"
13-
14- "github.com/spf13/cobra"
15-)
16-
17-// newAttachmentsCmd creates the attachments command group.
18-func newAttachmentsCmd() *cobra.Command {
19- cmd := &cobra.Command{
20- Use: "attachments",
21- Aliases: []string{"attach"},
22- Short: "Manage container attachments",
23- Long: `Manage container attachments (databases, caches, queues) in the configuration.
24-
25-Attachments are service dependencies that run alongside your application containers.
26-They are defined per-domain or per-network-group in the configuration.
27-
28-When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
29-these commands operate on the remote server. Otherwise, they require access to
30-the local Gordon configuration.`,
31- }
32-
33- cmd.AddCommand(newAttachmentsListCmd())
34- cmd.AddCommand(newAttachmentsAddCmd())
35- cmd.AddCommand(newAttachmentsRemoveCmd())
36- cmd.AddCommand(newAttachmentsOrphansCmd())
37- cmd.AddCommand(newAttachmentsPruneCmd())
38- cmd.AddCommand(newAttachmentsPushCmd())
39-
40- return cmd
41-}
42-
43-// newAttachmentsListCmd creates the attachments list command.
44-func newAttachmentsListCmd() *cobra.Command {
45- var jsonOut bool
46-
47- cmd := &cobra.Command{
48- Use: "list [domain-or-group]",
49- Short: "List configured attachments",
50- Long: `List all configured attachments, or attachments for a specific domain/group.
51-
52-Examples:
53- gordon attachments list # List all attachments
54- gordon attachments list app.example.com # List attachments for domain
55- gordon attachments list backend # List attachments for network group`,
56- Args: cobra.MaximumNArgs(1),
57- RunE: func(cmd *cobra.Command, args []string) error {
58- ctx := cmd.Context()
59- target := ""
60- if len(args) > 0 {
61- target = args[0]
62- }
63-
64- if target != "" {
65- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
66- if err != nil {
67- return err
68- }
69- defer handle.close()
70- return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
71- }
72-
73- client, isRemote, err := GetRemoteClient()
74- if err != nil {
75- return err
76- }
77- if isRemote {
78- return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
79- }
80- return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
81- },
82- }
83-
84- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
85-
86- return cmd
87-}
88-
89-func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error {
90- if target != "" {
91- images, err := cp.GetAttachmentsConfig(ctx, target)
92- if err != nil {
93- return fmt.Errorf("failed to get attachments: %w", err)
94- }
95- if len(images) == 0 {
96- _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target)
97- return err
98- }
99- if jsonOut {
100- return writeJSON(out, map[string]any{"target": target, "images": images})
101- }
102- return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images)
103- }
104-
105- attachments, err := cp.GetAllAttachmentsConfig(ctx)
106- if err != nil {
107- return fmt.Errorf("failed to list attachments: %w", err)
108- }
109- if len(attachments) == 0 {
110- return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured"))
111- }
112- if jsonOut {
113- return writeJSON(out, map[string]any{"attachments": attachments})
114- }
115- return renderAllAttachmentsList(out, "Attachments", attachments)
116-}
117-
118-func renderAttachmentTargetList(out io.Writer, title string, images []string) error {
119- if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
120- return err
121- }
122- if err := cliWriteLine(out, ""); err != nil {
123- return err
124- }
125- for _, img := range images {
126- if err := cliWritef(out, " %s\n", img); err != nil {
127- return err
128- }
129- }
130- return nil
131-}
132-
133-func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error {
134- targets := make([]string, 0, len(attachments))
135- for t := range attachments {
136- targets = append(targets, t)
137- }
138- sort.Strings(targets)
139-
140- if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
141- return err
142- }
143- if err := cliWriteLine(out, ""); err != nil {
144- return err
145- }
146- for _, t := range targets {
147- images := attachments[t]
148- if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil {
149- return err
150- }
151- for _, img := range images {
152- if err := cliWritef(out, " %s\n", img); err != nil {
153- return err
154- }
155- }
156- }
157- return nil
158-}
159-
160-// runAttachmentsListRemote lists attachments from a remote Gordon instance.
161-func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
162- if target != "" {
163- // List for specific target
164- images, err := client.GetAttachmentsConfig(ctx, target)
165- if err != nil {
166- return fmt.Errorf("failed to get attachments: %w", err)
167- }
168-
169- if len(images) == 0 {
170- fmt.Printf("No attachments configured for '%s'\n", target)
171- return nil
172- }
173-
174- if jsonOut {
175- return writeJSON(out, map[string]any{"target": target, "images": images})
176- }
177-
178- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
179- fmt.Println()
180- for _, img := range images {
181- fmt.Printf(" %s\n", img)
182- }
183- return nil
184- }
185-
186- // List all attachments
187- attachments, err := client.GetAllAttachmentsConfig(ctx)
188- if err != nil {
189- return fmt.Errorf("failed to list attachments: %w", err)
190- }
191-
192- if len(attachments) == 0 {
193- fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
194- return nil
195- }
196-
197- if jsonOut {
198- return writeJSON(out, map[string]any{"attachments": attachments})
199- }
200-
201- // Sort targets for consistent output
202- targets := make([]string, 0, len(attachments))
203- for t := range attachments {
204- targets = append(targets, t)
205- }
206- sort.Strings(targets)
207-
208- fmt.Println(styles.Theme.Title.Render("Attachments"))
209- fmt.Println()
210-
211- for _, t := range targets {
212- images := attachments[t]
213- fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
214- for _, img := range images {
215- fmt.Printf(" %s\n", img)
216- }
217- }
218-
219- return nil
220-}
221-
222-// runAttachmentsListLocal lists attachments from local configuration.
223-func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
224- local, err := GetLocalServices(cfgPath)
225- if err != nil {
226- return fmt.Errorf("failed to initialize local services: %w", err)
227- }
228-
229- if target != "" {
230- // List for specific target
231- images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
232- if err != nil {
233- return fmt.Errorf("failed to get attachments: %w", err)
234- }
235-
236- if len(images) == 0 {
237- fmt.Printf("No attachments configured for '%s'\n", target)
238- return nil
239- }
240-
241- if jsonOut {
242- return writeJSON(out, map[string]any{"target": target, "images": images})
243- }
244-
245- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
246- fmt.Println()
247- for _, img := range images {
248- fmt.Printf(" %s\n", img)
249- }
250- return nil
251- }
252-
253- // List all attachments
254- attachments := local.GetConfigService().GetAllAttachments(ctx)
255-
256- if len(attachments) == 0 {
257- fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
258- return nil
259- }
260-
261- if jsonOut {
262- return writeJSON(out, map[string]any{"attachments": attachments})
263- }
264-
265- // Sort targets for consistent output
266- targets := make([]string, 0, len(attachments))
267- for t := range attachments {
268- targets = append(targets, t)
269- }
270- sort.Strings(targets)
271-
272- fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
273- fmt.Println()
274-
275- for _, t := range targets {
276- images := attachments[t]
277- fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
278- for _, img := range images {
279- fmt.Printf(" %s\n", img)
280- }
281- }
282-
283- return nil
284-}
285-
286-func newAttachmentsOrphansCmd() *cobra.Command {
287- var jsonOut bool
288- cmd := &cobra.Command{
289- Use: "orphans",
290- Short: "List orphaned attachment containers",
291- Args: cobra.NoArgs,
292- RunE: func(cmd *cobra.Command, args []string) error {
293- handle, err := resolveControlPlane(configPath)
294- if err != nil {
295- return err
296- }
297- defer handle.close()
298- lister, ok := handle.plane.(interface {
299- ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
300- })
301- if !ok {
302- return fmt.Errorf("attachment orphan listing is unavailable")
303- }
304- attachments, err := lister.ListOrphanedAttachments(cmd.Context())
305- if err != nil {
306- return fmt.Errorf("failed to list orphaned attachments: %w", err)
307- }
308- if jsonOut {
309- return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments})
310- }
311- return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments)
312- },
313- }
314- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
315- return cmd
316-}
317-
318-func newAttachmentsPruneCmd() *cobra.Command {
319- var jsonOut bool
320- var stop bool
321- cmd := &cobra.Command{
322- Use: "prune",
323- Short: "Review or stop orphaned attachment containers",
324- Args: cobra.NoArgs,
325- RunE: func(cmd *cobra.Command, args []string) error {
326- handle, err := resolveControlPlane(configPath)
327- if err != nil {
328- return err
329- }
330- defer handle.close()
331- cleaner, ok := handle.plane.(interface {
332- CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
333- })
334- if !ok {
335- return fmt.Errorf("attachment orphan cleanup is unavailable")
336- }
337- report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop)
338- if err != nil {
339- return fmt.Errorf("failed to cleanup orphaned attachments: %w", err)
340- }
341- if jsonOut {
342- return writeJSON(cmd.OutOrStdout(), report)
343- }
344- return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop)
345- },
346- }
347- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
348- cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes")
349- return cmd
350-}
351-
352-func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error {
353- if len(attachments) == 0 {
354- return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found"))
355- }
356- if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil {
357- return err
358- }
359- for _, a := range attachments {
360- label := a.Name
361- if label == "" {
362- label = a.ContainerID
363- }
364- if a.Image != "" {
365- label += " -> " + a.Image
366- }
367- if err := cliWriteLine(out, " "+label); err != nil {
368- return err
369- }
370- }
371- return nil
372-}
373-
374-func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error {
375- if report == nil {
376- return nil
377- }
378- if !stop {
379- if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil {
380- return err
381- }
382- return writeCleanupMessages(out, report.Warnings, report.Hints)
383- }
384- if len(report.RemovedContainers) == 0 {
385- if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil {
386- return err
387- }
388- } else {
389- if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil {
390- return err
391- }
392- for _, c := range report.RemovedContainers {
393- label := c.Name
394- if label == "" {
395- label = c.ID
396- }
397- if err := cliWriteLine(out, " "+label); err != nil {
398- return err
399- }
400- }
401- }
402- for _, failure := range report.PartialFailures {
403- if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil {
404- return err
405- }
406- }
407- return writeCleanupMessages(out, report.Warnings, report.Hints)
408-}
409-
410-// newAttachmentsAddCmd creates the attachments add command.
411-func newAttachmentsAddCmd() *cobra.Command {
412- cmd := &cobra.Command{
413- Use: "add <domain-or-group> <image>",
414- Short: "Add an attachment",
415- Long: `Add a container attachment to a domain or network group.
416-
417-Note: Attachments require network_isolation.enabled = true in your configuration.
418-Without network isolation, containers use Docker's default bridge network which
419-does not provide DNS resolution - your app won't be able to reach attachments
420-by hostname (e.g., 'postgres:5432').
421-
422-Examples:
423- gordon attachments add app.example.com postgres:18
424- gordon attachments add backend redis:7-alpine
425- gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
426- Args: cobra.ExactArgs(2),
427- RunE: func(cmd *cobra.Command, args []string) error {
428- ctx := cmd.Context()
429- target := args[0]
430- image := args[1]
431-
432- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
433- if err != nil {
434- return err
435- }
436- defer handle.close()
437-
438- if !handle.isRemote {
439- local, err := GetLocalServices(configPath)
440- if err != nil {
441- return fmt.Errorf("failed to initialize local services: %w", err)
442- }
443-
444- // Warn if network isolation is disabled
445- if !local.GetConfigService().IsNetworkIsolationEnabled() {
446- fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
447- fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution."))
448- fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname."))
449- fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
450- fmt.Println()
451- }
452- }
453-
454- if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
455- return fmt.Errorf("failed to add attachment: %w", err)
456- }
457-
458- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
459- return nil
460- },
461- }
462-
463- return cmd
464-}
465-
466-// newAttachmentsRemoveCmd creates the attachments remove command.
467-func newAttachmentsRemoveCmd() *cobra.Command {
468- var force bool
469-
470- cmd := &cobra.Command{
471- Use: "remove <domain-or-group> <image>",
472- Short: "Remove an attachment",
473- Long: `Remove a container attachment from a domain or network group.
474-
475-Examples:
476- gordon attachments remove app.example.com postgres:15
477- gordon attachments remove backend redis:7-alpine --force`,
478- Args: cobra.ExactArgs(2),
479- RunE: func(cmd *cobra.Command, args []string) error {
480- ctx := cmd.Context()
481- target := args[0]
482- image := args[1]
483-
484- // Confirm unless --force
485- if !force {
486- confirmed, err := components.RunConfirm(
487- fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
488- components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
489- )
490- if err != nil {
491- return err
492- }
493- if !confirmed {
494- fmt.Println(styles.Theme.Muted.Render("Cancelled"))
495- return nil
496- }
497- }
498-
499- handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
500- if err != nil {
501- return err
502- }
503- defer handle.close()
504- if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
505- return fmt.Errorf("failed to remove attachment: %w", err)
506- }
507-
508- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
509- return nil
510- },
511- }
512-
513- cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
514-
515- return cmd
516-}
D internal/adapters/in/cli/attachments_push.go +0 -221
@@ -1,221 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8- "strings"
9-
10- "github.com/spf13/cobra"
11-
12- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
13- "github.com/bnema/gordon/pkg/validation"
14-)
15-
16-var (
17- resolveControlPlaneFn = resolveControlPlane
18- determineVersionFn = determineVersion
19-)
20-
21-// attachmentPushRequest holds all inputs for the attachments push command.
22-type attachmentPushRequest struct {
23- ImageArg string
24- Tag string
25- Build buildConfig
26-}
27-
28-func newAttachmentsPushCmd() *cobra.Command {
29- var (
30- build bool
31- platform string
32- tag string
33- dockerfile string
34- buildArgs []string
35- )
36-
37- cmd := &cobra.Command{
38- Use: "push <image>",
39- Short: "Build, tag, and push an attachment image",
40- Args: cobra.ExactArgs(1),
41- RunE: func(cmd *cobra.Command, args []string) error {
42- return runAttachmentsPush(cmd.Context(), attachmentPushRequest{
43- ImageArg: args[0],
44- Tag: tag,
45- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
46- }, cmd.OutOrStdout())
47- },
48- }
49-
50- cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
51- cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
52- cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
53- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
54- cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
55-
56- return cmd
57-}
58-
59-func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error {
60- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
61- if err != nil {
62- return err
63- }
64-
65- inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg)
66- if err != nil {
67- return err
68- }
69-
70- var handle *controlPlaneHandle
71- if inferredRemote != nil {
72- handle = newRemoteControlPlaneHandle(inferredRemote)
73- if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
74- return err
75- }
76- } else {
77- handle, err = resolveControlPlaneFn(configPath)
78- if err != nil {
79- return err
80- }
81- }
82- defer handle.close()
83-
84- for _, ba := range req.Build.BuildArgs {
85- if err := validateBuildArg(ba); err != nil {
86- return err
87- }
88- }
89-
90- registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, req.ImageArg)
91- if err != nil {
92- return err
93- }
94-
95- version, err := resolveVersionWithFn(ctx, req.Tag)
96- if err != nil {
97- return err
98- }
99-
100- img := imagePush{
101- Registry: registry,
102- ImageName: imageName,
103- Version: version,
104- }
105- img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
106-
107- var imageOps pushImageOps
108- if inferredRemote != nil {
109- imageOps, err = newImageOpsForResolvedRemote(inferredRemote)
110- } else {
111- imageOps, err = newImageOpsFn()
112- }
113- if err != nil {
114- return err
115- }
116-
117- if err := printAttachmentPushInfo(out, req.ImageArg, targets, img.VersionRef, img.LatestRef, version); err != nil {
118- return err
119- }
120-
121- build := buildConfig{
122- Enabled: req.Build.Enabled,
123- Platform: req.Build.Platform,
124- Dockerfile: dockerfile,
125- BuildArgs: req.Build.BuildArgs,
126- }
127-
128- if err := performAttachmentPush(ctx, imageOps, build, img); err != nil {
129- return err
130- }
131-
132- return cliWriteLine(out, styles.RenderSuccess("Push complete"))
133-}
134-
135-func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
136- if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
137- return err
138- }
139- if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
140- return err
141- }
142- if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
143- return err
144- }
145- if version != "latest" {
146- if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
147- return err
148- }
149- }
150- return nil
151-}
152-
153-func performAttachmentPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
154- if build.Enabled {
155- return buildAndPush(ctx, ops, build, img)
156- }
157- return tagAndPush(ctx, ops, img)
158-}
159-
160-func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
161- imageName = normalizeAttachmentImageName(imageArg)
162-
163- targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
164- if err != nil {
165- return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
166- }
167- if len(targets) == 0 {
168- return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
169- }
170-
171- status, err := cp.GetStatus(ctx)
172- if err != nil {
173- return "", "", nil, fmt.Errorf("failed to get status: %w", err)
174- }
175-
176- return status.RegistryDomain, imageName, targets, nil
177-}
178-
179-func normalizeAttachmentImageName(imageArg string) string {
180- name := imageArg
181- if idx := strings.Index(name, "@"); idx != -1 {
182- name = name[:idx]
183- }
184- if idx := strings.LastIndex(name, ":"); idx != -1 {
185- slashIdx := strings.LastIndex(name, "/")
186- if idx > slashIdx {
187- name = name[:idx]
188- }
189- }
190- parts := strings.SplitN(name, "/", 2)
191- if len(parts) == 2 {
192- host := parts[0]
193- if strings.ContainsAny(host, ".:") || host == "localhost" {
194- return parts[1]
195- }
196- }
197- return name
198-}
199-
200-func formatAttachmentTargets(targets []string) string {
201- cloned := append([]string(nil), targets...)
202- sort.Strings(cloned)
203- return fmt.Sprintf("%v", cloned)
204-}
205-
206-func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
207- version := determineVersionFn(ctx, tag)
208- if version != "latest" {
209- if err := validateVersionTag(version); err != nil {
210- return "", err
211- }
212- }
213- return version, nil
214-}
215-
216-func validateVersionTag(version string) error {
217- if err := validation.ValidateReference(version); err != nil {
218- return fmt.Errorf("invalid version tag %q: %w", version, err)
219- }
220- return nil
221-}
D internal/adapters/in/cli/attachments_push_test.go +0 -145
@@ -1,145 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "testing"
7-
8- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
9- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/mock"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestResolveAttachmentImage_OneTarget(t *testing.T) {
16- cpMock := climocks.NewMockControlPlane(t)
17- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once()
18- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
19-
20- registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "postgres")
21-
22- require.NoError(t, err)
23- assert.Equal(t, "registry.example.com", registry)
24- assert.Equal(t, "postgres", imageName)
25- assert.Equal(t, []string{"app.example.com"}, targets)
26-}
27-
28-func TestResolveAttachmentImage_MultipleTargets(t *testing.T) {
29- cpMock := climocks.NewMockControlPlane(t)
30- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "redis").Return([]string{"app.example.com", "backend"}, nil).Once()
31- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
32-
33- registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "redis")
34-
35- require.NoError(t, err)
36- assert.Equal(t, "registry.example.com", registry)
37- assert.Equal(t, "redis", imageName)
38- assert.Equal(t, []string{"app.example.com", "backend"}, targets)
39-}
40-
41-func TestResolveAttachmentImage_NotConfigured(t *testing.T) {
42- cpMock := climocks.NewMockControlPlane(t)
43- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return(nil, nil).Once()
44-
45- _, _, _, err := resolveAttachmentImage(context.Background(), cpMock, "postgres")
46-
47- require.Error(t, err)
48- assert.Contains(t, err.Error(), "not configured as an attachment")
49-}
50-
51-func TestAttachmentPushCmd_NoDeploy(t *testing.T) {
52- origResolveControlPlane := resolveControlPlaneFn
53- origDetermineVersion := determineVersionFn
54- origNewImageOps := newImageOpsFn
55- t.Cleanup(func() {
56- resolveControlPlaneFn = origResolveControlPlane
57- determineVersionFn = origDetermineVersion
58- newImageOpsFn = origNewImageOps
59- })
60-
61- cpMock := climocks.NewMockControlPlane(t)
62- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once()
63- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
64-
65- resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) {
66- return &controlPlaneHandle{plane: cpMock}, nil
67- }
68- determineVersionFn = func(context.Context, string) string { return "v1.2.3" }
69- pushCalled := false
70- newImageOpsFn = func() (pushImageOps, error) {
71- m := climocks.NewMockpushImageOps(t)
72- m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
73- m.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
74- m.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
75- pushCalled = true
76- }).Return(nil)
77- return m, nil
78- }
79-
80- cmd := newAttachmentsPushCmd()
81- out := new(bytes.Buffer)
82- cmd.SetOut(out)
83- cmd.SetErr(out)
84- cmd.SetArgs([]string{"postgres"})
85-
86- err := cmd.Execute()
87-
88- require.NoError(t, err)
89- assert.True(t, pushCalled)
90- assert.Contains(t, out.String(), "Push complete")
91-}
92-
93-func TestAttachmentPushCmd_TaggedImageInputBuildsValidRefs(t *testing.T) {
94- origResolveControlPlane := resolveControlPlaneFn
95- origDetermineVersion := determineVersionFn
96- origNewImageOps := newImageOpsFn
97- t.Cleanup(func() {
98- resolveControlPlaneFn = origResolveControlPlane
99- determineVersionFn = origDetermineVersion
100- newImageOpsFn = origNewImageOps
101- })
102-
103- cpMock := climocks.NewMockControlPlane(t)
104- cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres:18").Return([]string{"app.example.com"}, nil).Once()
105- cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once()
106-
107- resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) {
108- return &controlPlaneHandle{plane: cpMock}, nil
109- }
110- determineVersionFn = func(context.Context, string) string { return "v1.2.3" }
111-
112- var gotVersionRef string
113- var gotLatestRef string
114- newImageOpsFn = func() (pushImageOps, error) {
115- m := climocks.NewMockpushImageOps(t)
116- m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
117- m.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
118- m.On("Push", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
119- ref := args.Get(1).(string)
120- if gotVersionRef == "" {
121- gotVersionRef = ref
122- } else if gotLatestRef == "" {
123- gotLatestRef = ref
124- }
125- }).Return(nil)
126- return m, nil
127- }
128-
129- cmd := newAttachmentsPushCmd()
130- cmd.SetOut(new(bytes.Buffer))
131- cmd.SetErr(new(bytes.Buffer))
132- cmd.SetArgs([]string{"postgres:18"})
133-
134- err := cmd.Execute()
135-
136- require.NoError(t, err)
137- assert.Equal(t, "registry.example.com/postgres:v1.2.3", gotVersionRef)
138- assert.Equal(t, "registry.example.com/postgres:latest", gotLatestRef)
139-}
140-
141-func TestNormalizeAttachmentImageName_PreservesNamespaceWithoutRegistryHost(t *testing.T) {
142- got := normalizeAttachmentImageName("myorg/postgres:18")
143-
144- assert.Equal(t, "myorg/postgres", got)
145-}
M internal/adapters/in/cli/auth.go +88 -61
@@ -60,11 +60,13 @@ func newAuthTokenCmd() *cobra.Command {
6060 // newTokenGenerateCmd creates the token generate command.
6161 func newTokenGenerateCmd() *cobra.Command {
6262 var (
63- subject string
64- scopes string
65- expiry string
66- configPath string
67- repo string
63+ subject string
64+ scopes string
65+ expiry string
66+ cliConfigPath string
67+ repo string
68+ rawOut bool
69+ jsonOut bool
6870 )
6971
7072 cmd := &cobra.Command{
@@ -84,12 +86,13 @@ Registry scopes (for Docker push/pull):
8486 Admin scopes (for remote CLI access):
8587 Format: admin:<resource>:<actions>
8688
87- Resources: routes, secrets, config, status, logs, volumes, * (all)
89+ Resources: apps, secrets, config, status, logs, volumes, * (all)
8890 Actions: read, write, * (all)
8991
9092 Examples:
9193 admin:*:* Full admin access (recommended for CLI)
92- admin:routes:read Read-only routes access
94+ admin:apps:read Read-only apps access
95+ admin:apps:write App mutations (apply, deploy, lifecycle)
9396 admin:status:read Read-only status access
9497 admin:logs:read Read-only log access
9598 admin:volumes:read Read-only volume access
@@ -102,8 +105,8 @@ Repository scoping:
102105 Combined examples:
103106 --scopes "push,pull" All repos, registry only (default)
104107 --scopes "push" --repo myapp Push to myapp only
105- --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
106- --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108+ --scopes "push,pull,admin:apps:read" Minimum CI scope (all repos)
109+ --scopes "push,admin:apps:read" --repo app Minimum CI scope (specific repo)
107110
108111 EXPIRY:
109112
@@ -114,15 +117,18 @@ Supports human-friendly durations:
114117 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
115118 Use --expiry=0 for a token that never expires (useful for CI).`,
116119 RunE: func(cmd *cobra.Command, args []string) error {
117- return runTokenGenerate(subject, scopes, expiry, configPath, repo)
120+ return runTokenGenerate(subject, scopes, expiry, cliConfigPath, repo, cmd.OutOrStdout(), rawOut, jsonOut)
118121 },
119122 }
120123
121124 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
122125 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
123126 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
124- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
127+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
125128 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
129+ cmd.Flags().BoolVar(&rawOut, "raw", false, "Output only the raw token")
130+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
131+ cmd.MarkFlagsMutuallyExclusive("raw", "json")
126132
127133 _ = cmd.MarkFlagRequired("subject")
128134
@@ -132,8 +138,8 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
132138 // newTokenListCmd creates the token list command.
133139 func newTokenListCmd() *cobra.Command {
134140 var (
135- configPath string
136- jsonOut bool
141+ cliConfigPath string
142+ jsonOut bool
137143 )
138144
139145 cmd := &cobra.Command{
@@ -141,11 +147,11 @@ func newTokenListCmd() *cobra.Command {
141147 Short: "List all authentication tokens",
142148 Long: `List all stored authentication tokens with their subjects and expiry information.`,
143149 RunE: func(cmd *cobra.Command, args []string) error {
144- return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
150+ return runTokenList(cliConfigPath, cmd.OutOrStdout(), jsonOut)
145151 },
146152 }
147153
148- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
154+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
149155 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
150156
151157 return cmd
@@ -154,8 +160,8 @@ func newTokenListCmd() *cobra.Command {
154160 // newTokenRevokeCmd creates the token revoke command.
155161 func newTokenRevokeCmd() *cobra.Command {
156162 var (
157- configPath string
158- all bool
163+ cliConfigPath string
164+ all bool
159165 )
160166
161167 cmd := &cobra.Command{
@@ -169,16 +175,16 @@ Examples:
169175 Args: cobra.MaximumNArgs(1),
170176 RunE: func(cmd *cobra.Command, args []string) error {
171177 if all {
172- return runTokenRevokeAll(configPath)
178+ return runTokenRevokeAll(cliConfigPath)
173179 }
174180 if len(args) == 0 {
175181 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
176182 }
177- return runTokenRevoke(args[0], configPath)
183+ return runTokenRevoke(args[0], cliConfigPath)
178184 },
179185 }
180186
181- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
187+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
182188 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
183189
184190 return cmd
@@ -188,18 +194,21 @@ Examples:
188194 func newAuthInternalCmd() *cobra.Command {
189195 return &cobra.Command{
190196 Use: "internal",
191- Short: "Show internal registry credentials for manual recovery",
192- Long: `Display the auto-generated internal registry credentials.
197+ Short: "Show server-local registry recovery credentials",
198+ Long: `Display the server's auto-generated internal registry credentials.
199+
200+This sensitive recovery command is available only when Gordon runs locally on
201+the server. The daemon normally uses these ephemeral credentials automatically
202+to pull from its registry; remote clients and ordinary pushes use scoped tokens.
193203
194-These credentials are used by Gordon for pulling images from the local registry
195-(localhost:5000) during internal deploys. You can use them for manual recovery:
204+For local recovery only:
196205
197- docker login localhost:5000 -u gordon-internal -p <password>
206+ gordon auth internal
207+ docker login localhost:<registry-port> -u gordon-internal --password-stdin
198208
199-Note: Credentials are regenerated each time Gordon starts, and are only
200-available while Gordon is running.`,
209+Credentials are regenerated whenever the Gordon daemon starts.`,
201210 RunE: func(cmd *cobra.Command, args []string) error {
202- return runShowInternalAuth()
211+ return runShowInternalAuth(cmd.OutOrStdout())
203212 },
204213 }
205214 }
@@ -402,21 +411,31 @@ func runAuthLogout(out io.Writer) error {
402411 }
403412
404413 // runShowInternalAuth displays the internal registry credentials.
405-func runShowInternalAuth() error {
414+func runShowInternalAuth(out io.Writer) error {
415+ if _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag); err != nil {
416+ return err
417+ } else if isRemote {
418+ return fmt.Errorf("auth internal is server-local only; run it directly on the Gordon server")
419+ }
420+
406421 creds, err := app.GetInternalCredentials()
407422 if err != nil {
408423 return err
409424 }
410425
411- fmt.Println("Internal Registry Credentials")
412- fmt.Println("==============================")
413- fmt.Printf("Username: %s\n", creds.Username)
414- fmt.Printf("Password: %s\n", creds.Password)
415- fmt.Println()
416- fmt.Println("Usage:")
417- fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
418-
419- return nil
426+ if err := cliWriteLine(out, "Internal Registry Credentials"); err != nil {
427+ return err
428+ }
429+ if err := cliWriteLine(out, "=============================="); err != nil {
430+ return err
431+ }
432+ if err := cliWritef(out, "Username: %s\n", creds.Username); err != nil {
433+ return err
434+ }
435+ if err := cliWritef(out, "Password: %s\n", creds.Password); err != nil {
436+ return err
437+ }
438+ return cliWriteLine(out, "\nThese credentials are sensitive and intended only for server-local recovery.")
420439 }
421440
422441 // runTokenGenerate generates a new authentication token.
@@ -463,8 +482,8 @@ func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
463482 return scopes, nil
464483 }
465484
466-func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
467- cfg, err := loadAuthConfig(configPath)
485+func runTokenGenerate(subject, scopesStr, expiryStr, cliConfigPath, repo string, out io.Writer, rawOut, jsonOut bool) error {
486+ cfg, err := loadAuthConfig(cliConfigPath)
468487 if err != nil {
469488 return err
470489 }
@@ -497,26 +516,34 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
497516 return fmt.Errorf("failed to generate token: %w", err)
498517 }
499518
500- fmt.Println("Token generated successfully!")
501- fmt.Printf("Subject: %s\n", subject)
502- fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
503- if expiry == 0 {
504- fmt.Println("Expiry: never")
505- } else {
506- fmt.Printf("Expiry: %s\n", expiry)
519+ if rawOut {
520+ return cliWriteLine(out, token)
521+ }
522+ if jsonOut {
523+ return writeJSON(out, map[string]any{
524+ "subject": subject,
525+ "scopes": scopes,
526+ "expiry": expiry.String(),
527+ "token": token,
528+ })
507529 }
508- fmt.Println()
509- fmt.Println("Token (use as password with docker login):")
510- fmt.Println(token)
511- fmt.Println()
512- fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
513530
514- return nil
531+ if err := cliWriteLine(out, "Token generated successfully!"); err != nil {
532+ return err
533+ }
534+ if err := cliWritef(out, "Subject: %s\nScopes: %s\n", subject, strings.Join(scopes, ", ")); err != nil {
535+ return err
536+ }
537+ expiryLabel := expiry.String()
538+ if expiry == 0 {
539+ expiryLabel = "never"
540+ }
541+ return cliWritef(out, "Expiry: %s\n\nToken (use as password with docker login):\n%s\n\nUsage: docker login -u %s -p <token> <registry>\n", expiryLabel, token, subject)
515542 }
516543
517544 // runTokenList lists all stored tokens.
518-func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
519- cfg, err := loadAuthConfig(configPath)
545+func runTokenList(cliConfigPath string, out io.Writer, jsonOut bool) error {
546+ cfg, err := loadAuthConfig(cliConfigPath)
520547 if err != nil {
521548 return err
522549 }
@@ -579,8 +606,8 @@ func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
579606 }
580607
581608 // runTokenRevoke revokes a token by ID.
582-func runTokenRevoke(tokenID, configPath string) error {
583- cfg, err := loadAuthConfig(configPath)
609+func runTokenRevoke(tokenID, cliConfigPath string) error {
610+ cfg, err := loadAuthConfig(cliConfigPath)
584611 if err != nil {
585612 return err
586613 }
@@ -601,8 +628,8 @@ func runTokenRevoke(tokenID, configPath string) error {
601628 }
602629
603630 // runTokenRevokeAll revokes all tokens.
604-func runTokenRevokeAll(configPath string) error {
605- cfg, err := loadAuthConfig(configPath)
631+func runTokenRevokeAll(cliConfigPath string) error {
632+ cfg, err := loadAuthConfig(cliConfigPath)
606633 if err != nil {
607634 return err
608635 }
@@ -635,14 +662,14 @@ type cliConfig struct {
635662 }
636663
637664 // loadAuthConfig loads the configuration needed for auth CLI commands.
638-func loadAuthConfig(configPath string) (*cliConfig, error) {
665+func loadAuthConfig(cliConfigPath string) (*cliConfig, error) {
639666 v := viper.New()
640667
641668 // Set defaults
642669 v.SetDefault("server.data_dir", app.DefaultDataDir())
643670 v.SetDefault("auth.secrets_backend", "unsafe")
644671
645- app.ConfigureViper(v, configPath)
672+ app.ConfigureViper(v, cliConfigPath)
646673
647674 if err := v.ReadInConfig(); err != nil {
648675 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
M internal/adapters/in/cli/auth_scopes_test.go +2 -2
@@ -26,9 +26,9 @@ func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
2626 }
2727
2828 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
29- got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
29+ got, err := parseAndConvertScopes("push,pull,admin:apps:read", "myapp")
3030 require.NoError(t, err)
31- assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
31+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:apps:read"}, got)
3232 }
3333
3434 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
D internal/adapters/in/cli/autoroute_allow.go +0 -112
@@ -1,112 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7-
8- "github.com/spf13/cobra"
9-)
10-
11-func newAutorouteCmd() *cobra.Command {
12- cmd := &cobra.Command{
13- Use: "autoroute",
14- Short: "Manage auto-route settings",
15- }
16- cmd.AddCommand(newAutorouteAllowCmd())
17- return cmd
18-}
19-
20-func newAutorouteAllowCmd() *cobra.Command {
21- cmd := &cobra.Command{
22- Use: "allow",
23- Short: "Manage auto-route allowed domains",
24- }
25- cmd.AddCommand(newAutorouteAllowAddCmd())
26- cmd.AddCommand(newAutorouteAllowListCmd())
27- cmd.AddCommand(newAutorouteAllowRemoveCmd())
28- return cmd
29-}
30-
31-func newAutorouteAllowAddCmd() *cobra.Command {
32- return &cobra.Command{
33- Use: "add <pattern>",
34- Short: "Allow an auto-route domain pattern",
35- Args: cobra.ExactArgs(1),
36- RunE: func(cmd *cobra.Command, args []string) error {
37- return runAutorouteAllowAdd(cmd.Context(), args, cmd.OutOrStdout())
38- },
39- }
40-}
41-
42-func newAutorouteAllowListCmd() *cobra.Command {
43- var jsonOut bool
44- cmd := &cobra.Command{
45- Use: "list",
46- Short: "List auto-route allowed domains",
47- RunE: func(cmd *cobra.Command, args []string) error {
48- return runAutorouteAllowList(cmd.Context(), cmd.OutOrStdout(), jsonOut)
49- },
50- }
51- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
52- return cmd
53-}
54-
55-func newAutorouteAllowRemoveCmd() *cobra.Command {
56- return &cobra.Command{
57- Use: "remove <pattern>",
58- Short: "Remove an auto-route domain pattern",
59- Args: cobra.ExactArgs(1),
60- RunE: func(cmd *cobra.Command, args []string) error {
61- return runAutorouteAllowRemove(cmd.Context(), args, cmd.OutOrStdout())
62- },
63- }
64-}
65-
66-func runAutorouteAllowAdd(ctx context.Context, args []string, out io.Writer) error {
67- handle, err := resolveControlPlane(configPath)
68- if err != nil {
69- return err
70- }
71- defer handle.close()
72- if err := handle.plane.AddAutoRouteAllowedDomain(ctx, args[0]); err != nil {
73- return fmt.Errorf("failed to add auto-route allowed domain: %w", err)
74- }
75- return cliWriteLine(out, cliRenderSuccess("Allowed domain added"))
76-}
77-
78-func runAutorouteAllowList(ctx context.Context, out io.Writer, jsonOut bool) error {
79- handle, err := resolveControlPlane(configPath)
80- if err != nil {
81- return err
82- }
83- defer handle.close()
84- domains, err := handle.plane.GetAutoRouteAllowedDomains(ctx)
85- if err != nil {
86- return fmt.Errorf("failed to list auto-route allowed domains: %w", err)
87- }
88- if jsonOut {
89- return writeJSON(out, map[string]any{"domains": domains})
90- }
91- if len(domains) == 0 {
92- return cliWriteLine(out, cliRenderMuted("No allowed domains configured"))
93- }
94- for _, domain := range domains {
95- if err := cliWriteLine(out, domain); err != nil {
96- return err
97- }
98- }
99- return nil
100-}
101-
102-func runAutorouteAllowRemove(ctx context.Context, args []string, out io.Writer) error {
103- handle, err := resolveControlPlane(configPath)
104- if err != nil {
105- return err
106- }
107- defer handle.close()
108- if err := handle.plane.RemoveAutoRouteAllowedDomain(ctx, args[0]); err != nil {
109- return fmt.Errorf("failed to remove auto-route allowed domain: %w", err)
110- }
111- return cliWriteLine(out, cliRenderSuccess("Allowed domain removed"))
112-}
M internal/adapters/in/cli/backup.go +157 -230
@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66 "io"
7- "os"
87 "text/tabwriter"
98 "time"
109
@@ -14,54 +13,35 @@ import (
1413 "github.com/bnema/gordon/pkg/bytesize"
1514 )
1615
17-var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18- if domainName != "" {
19- return resolveControlPlaneForRouteDomain(ctx, domainName)
20- }
21- return resolveControlPlane(configPath)
22-}
23-
24-// newBackupCmd creates the backup command group.
16+// newBackupCmd creates the backup command group. Backups are identified by
17+// app, service, and the declared database or volume: a domain is never an
18+// identity.
2519 func newBackupCmd() *cobra.Command {
2620 cmd := &cobra.Command{
2721 Use: "backups",
2822 Aliases: []string{"backup"},
29- Short: "Manage backups",
30- Long: `Manage backups.
23+ Short: "Manage app backups",
24+ Long: `Manage app backups.
3125
32-Runs locally via in-process services by default, or against a remote Gordon
33-instance when --remote targeting is configured.`,
26+Backup targets come from the app manifest: a service declares its databases
27+and volumes, and which of them are backed up. Runs locally through the daemon's
28+authenticated Unix socket, or against a remote Gordon instance when --remote
29+targeting is configured.`,
3430 }
3531
36- cmd.AddCommand(newBackupDatabasesCmd())
37- cmd.AddCommand(newBackupVolumesCmd())
38-
39- // Compatibility aliases for the original database backup command shape.
4032 cmd.AddCommand(newBackupListCmd())
4133 cmd.AddCommand(newBackupRunCmd())
42- cmd.AddCommand(newBackupDetectCmd())
4334 cmd.AddCommand(newBackupStatusCmd())
35+ cmd.AddCommand(newBackupVolumeCmd())
4436
4537 return cmd
4638 }
4739
48-func newBackupDatabasesCmd() *cobra.Command {
49- cmd := &cobra.Command{
50- Use: "databases",
51- Aliases: []string{"database", "db"},
52- Short: "Manage database backups",
53- }
54- cmd.AddCommand(newBackupListCmd())
55- cmd.AddCommand(newBackupRunCmd())
56- cmd.AddCommand(newBackupDetectCmd())
57- cmd.AddCommand(newBackupStatusCmd())
58- return cmd
59-}
60-
61-func newBackupVolumesCmd() *cobra.Command {
40+// newBackupVolumeCmd creates the `backup volume` group.
41+func newBackupVolumeCmd() *cobra.Command {
6242 cmd := &cobra.Command{
63- Use: "volumes",
64- Short: "Manage volume backups",
43+ Use: "volume",
44+ Short: "Manage app volume backups",
6545 }
6646 cmd.AddCommand(newVolumeBackupListCmd())
6747 cmd.AddCommand(newVolumeBackupRunCmd())
@@ -73,44 +53,36 @@ func newBackupListCmd() *cobra.Command {
7353 var jsonOut bool
7454
7555 cmd := &cobra.Command{
76- Use: "list [domain]",
77- Short: "List backups",
78- Long: cliRenderMuted("List backups for all domains or a specific domain."),
56+ Use: "list [APP]",
57+ Short: "List database backups",
58+ Long: cliRenderMuted("List stored backups for all apps, or for one app."),
7959 Args: cobra.MaximumNArgs(1),
8060 RunE: func(cmd *cobra.Command, args []string) error {
81- domainName := ""
61+ app := ""
8262 if len(args) == 1 {
83- domainName = args[0]
84- }
85-
86- var (
87- handle *controlPlaneHandle
88- err error
89- )
90- if domainName != "" {
91- handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName)
92- } else {
93- handle, err = resolveControlPlane(configPath)
63+ app = args[0]
9464 }
65+ handle, err := resolveControlPlane(cliConfigPath)
9566 if err != nil {
9667 return err
9768 }
9869 defer handle.close()
99-
100- jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
101- if err != nil {
102- return fmt.Errorf("failed to list backups: %w", err)
103- }
104-
105- return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
70+ return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
10671 },
10772 }
10873
10974 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
110-
11175 return cmd
11276 }
11377
78+func runBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
79+ jobs, err := plane.ListBackups(ctx, app)
80+ if err != nil {
81+ return fmt.Errorf("failed to list backups: %w", err)
82+ }
83+ return printBackupJobs(out, jobs, jsonOut)
84+}
85+
11486 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
11587 if len(jobs) == 0 {
11688 if jsonOut {
@@ -127,106 +99,192 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
12799 return err
128100 }
129101 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
130- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
102+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tDATABASE\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
131103 return err
132104 }
133-
134105 for _, job := range jobs {
135- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
106+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
107+ job.App, job.Service, job.Database, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
136108 return err
137109 }
138110 }
139111 return w.Flush()
140112 }
141113
142-func newVolumeBackupListCmd() *cobra.Command {
114+func newBackupRunCmd() *cobra.Command {
115+ var service string
116+ var database string
143117 var jsonOut bool
144118
145119 cmd := &cobra.Command{
146- Use: "list [domain]",
147- Short: "List volume backups",
148- Args: cobra.MaximumNArgs(1),
120+ Use: "run APP",
121+ Short: "Run a database backup now",
122+ Long: cliRenderMuted(`Run the declared database backup of one app service.
123+
124+--service and --database select the target. Omitting a selector is only
125+allowed when exactly one compatible target exists.`),
126+ Args: cobra.ExactArgs(1),
149127 RunE: func(cmd *cobra.Command, args []string) error {
150- domainName := ""
151- if len(args) == 1 {
152- domainName = args[0]
153- }
154- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
128+ handle, err := resolveControlPlane(cliConfigPath)
155129 if err != nil {
156130 return err
157131 }
158132 defer handle.close()
133+ return runBackupRun(cmd.Context(), handle.plane, args[0], service, database, cmd.OutOrStdout(), jsonOut)
134+ },
135+ }
136+
137+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the database")
138+ cmd.Flags().StringVar(&database, "database", "", "Declared database name (optional)")
139+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
140+ return cmd
141+}
142+
143+func runBackupRun(ctx context.Context, plane ControlPlane, app, service, database string, out io.Writer, jsonOut bool) error {
144+ result, err := plane.RunBackup(ctx, app, service, database)
145+ if err != nil {
146+ return fmt.Errorf("failed to run backup: %w", err)
147+ }
148+ if result.Backup == nil {
149+ return fmt.Errorf("backup run completed without a backup payload")
150+ }
151+ if jsonOut {
152+ return writeJSON(out, result)
153+ }
154+ return printBackupJobs(out, []dto.BackupJob{*result.Backup}, false)
155+}
159156
160- jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
157+func newBackupStatusCmd() *cobra.Command {
158+ var jsonOut bool
159+ cmd := &cobra.Command{
160+ Use: "status",
161+ Short: "Show database backup status",
162+ Long: cliRenderMuted("Show stored backups plus declared targets that have no completed backup yet."),
163+ RunE: func(cmd *cobra.Command, args []string) error {
164+ handle, err := resolveControlPlane(cliConfigPath)
161165 if err != nil {
162- return fmt.Errorf("failed to list volume backups: %w", err)
166+ return err
163167 }
164- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
168+ defer handle.close()
169+ return runBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
165170 },
166171 }
167172 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168173 return cmd
169174 }
170175
171-func newVolumeBackupRunCmd() *cobra.Command {
172- var volumeName string
176+func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
177+ jobs, err := plane.BackupStatus(ctx)
178+ if err != nil {
179+ return fmt.Errorf("failed to get backup status: %w", err)
180+ }
181+ return printBackupJobs(out, jobs, jsonOut)
182+}
183+
184+func newVolumeBackupListCmd() *cobra.Command {
173185 var jsonOut bool
174186
175187 cmd := &cobra.Command{
176- Use: "run [domain]",
177- Short: "Run volume backups now",
188+ Use: "list [APP]",
189+ Short: "List volume backups",
178190 Args: cobra.MaximumNArgs(1),
179191 RunE: func(cmd *cobra.Command, args []string) error {
180- domainName := ""
192+ app := ""
181193 if len(args) == 1 {
182- domainName = args[0]
194+ app = args[0]
183195 }
184- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
196+ handle, err := resolveControlPlane(cliConfigPath)
185197 if err != nil {
186198 return err
187199 }
188200 defer handle.close()
201+ return runVolumeBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
202+ },
203+ }
204+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
205+ return cmd
206+}
189207
190- result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
208+func runVolumeBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
209+ jobs, err := plane.ListVolumeBackups(ctx, app)
210+ if err != nil {
211+ return fmt.Errorf("failed to list volume backups: %w", err)
212+ }
213+ return printVolumeBackupJobs(out, jobs, jsonOut)
214+}
215+
216+func newVolumeBackupRunCmd() *cobra.Command {
217+ var service string
218+ var volume string
219+ var jsonOut bool
220+
221+ cmd := &cobra.Command{
222+ Use: "run APP",
223+ Short: "Run a volume backup now",
224+ Long: cliRenderMuted(`Run the declared volume backup of one app service.
225+
226+--service and --volume select the target. Omitting a selector is only
227+allowed when exactly one compatible target exists.`),
228+ Args: cobra.ExactArgs(1),
229+ RunE: func(cmd *cobra.Command, args []string) error {
230+ handle, err := resolveControlPlane(cliConfigPath)
191231 if err != nil {
192- if result != nil && len(result.Backups) > 0 {
193- if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
194- return printErr
195- }
196- }
197- return fmt.Errorf("failed to run volume backups: %w", err)
232+ return err
198233 }
199- return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
234+ defer handle.close()
235+ return runVolumeBackupRun(cmd.Context(), handle.plane, args[0], service, volume, cmd.OutOrStdout(), jsonOut)
200236 },
201237 }
202- cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
238+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the volume")
239+ cmd.Flags().StringVar(&volume, "volume", "", "Declared volume name (optional)")
203240 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204241 return cmd
205242 }
206243
244+func runVolumeBackupRun(ctx context.Context, plane ControlPlane, app, service, volume string, out io.Writer, jsonOut bool) error {
245+ result, err := plane.RunVolumeBackups(ctx, app, service, volume)
246+ if err != nil {
247+ // Partial results are reported before the error: an operator must
248+ // see which volumes completed.
249+ if result != nil && len(result.Backups) > 0 {
250+ if printErr := printVolumeBackupJobs(out, result.Backups, jsonOut); printErr != nil {
251+ return printErr
252+ }
253+ }
254+ return fmt.Errorf("failed to run volume backup: %w", err)
255+ }
256+ if jsonOut {
257+ return writeJSON(out, result)
258+ }
259+ return printVolumeBackupJobs(out, result.Backups, false)
260+}
261+
207262 func newVolumeBackupStatusCmd() *cobra.Command {
208263 var jsonOut bool
209264 cmd := &cobra.Command{
210265 Use: "status",
211266 Short: "Show volume backup status",
212267 RunE: func(cmd *cobra.Command, args []string) error {
213- handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
268+ handle, err := resolveControlPlane(cliConfigPath)
214269 if err != nil {
215270 return err
216271 }
217272 defer handle.close()
218-
219- jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
220- if err != nil {
221- return fmt.Errorf("failed to get volume backup status: %w", err)
222- }
223- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
273+ return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
224274 },
225275 }
226276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227277 return cmd
228278 }
229279
280+func runVolumeBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
281+ jobs, err := plane.VolumeBackupStatus(ctx)
282+ if err != nil {
283+ return fmt.Errorf("failed to get volume backup status: %w", err)
284+ }
285+ return printVolumeBackupJobs(out, jobs, jsonOut)
286+}
287+
230288 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231289 if len(jobs) == 0 {
232290 if jsonOut {
@@ -241,150 +299,19 @@ func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bo
241299 return err
242300 }
243301 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
244- if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
302+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tVOLUME\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
245303 return err
246304 }
247305 for _, job := range jobs {
248- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", job.Domain, job.VolumeName, job.ContainerName, job.Compression, job.Status, formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
306+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
307+ job.App, job.Service, job.VolumeName, job.Compression, job.Status,
308+ formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
249309 return err
250310 }
251311 }
252312 return w.Flush()
253313 }
254314
255-func newBackupRunCmd() *cobra.Command {
256- var dbName string
257-
258- cmd := &cobra.Command{
259- Use: "run <domain>",
260- Short: "Run backup now",
261- Args: cobra.ExactArgs(1),
262- RunE: func(cmd *cobra.Command, args []string) error {
263- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
264- if err != nil {
265- return err
266- }
267- defer handle.close()
268-
269- result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
270- if err != nil {
271- return fmt.Errorf("failed to run backup: %w", err)
272- }
273-
274- if result.Backup == nil {
275- return fmt.Errorf("backup run completed without backup payload")
276- }
277- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
278- return err
279- }
280-
281- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
282- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
283- return err
284- }
285- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
286- return err
287- }
288- if err := w.Flush(); err != nil {
289- return err
290- }
291- return nil
292- },
293- }
294-
295- cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)")
296- return cmd
297-}
298-
299-func newBackupDetectCmd() *cobra.Command {
300- return &cobra.Command{
301- Use: "detect <domain>",
302- Short: "Detect databases for domain",
303- Args: cobra.ExactArgs(1),
304- RunE: func(cmd *cobra.Command, args []string) error {
305- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
306- if err != nil {
307- return err
308- }
309- defer handle.close()
310-
311- dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
312- if err != nil {
313- return fmt.Errorf("failed to detect databases: %w", err)
314- }
315-
316- if len(dbs) == 0 {
317- if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
318- return err
319- }
320- return nil
321- }
322- if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
323- return err
324- }
325- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
326- if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
327- return err
328- }
329-
330- for _, db := range dbs {
331- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName); err != nil {
332- return err
333- }
334- }
335- if err := w.Flush(); err != nil {
336- return err
337- }
338-
339- return nil
340- },
341- }
342-}
343-
344-func newBackupStatusCmd() *cobra.Command {
345- return &cobra.Command{
346- Use: "status",
347- Short: "Show backup status",
348- RunE: func(cmd *cobra.Command, args []string) error {
349- handle, err := resolveControlPlane(configPath)
350- if err != nil {
351- return err
352- }
353- defer handle.close()
354-
355- jobs, err := handle.plane.BackupStatus(cmd.Context())
356- if err != nil {
357- return fmt.Errorf("failed to get backup status: %w", err)
358- }
359-
360- if len(jobs) == 0 {
361- if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
362- return err
363- }
364- return nil
365- }
366- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
367- return err
368- }
369- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
370- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
371- return err
372- }
373-
374- for _, job := range jobs {
375- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt)); err != nil {
376- return err
377- }
378- }
379- if err := w.Flush(); err != nil {
380- return err
381- }
382-
383- return nil
384- },
385- }
386-}
387-
388315 func formatBackupTime(t *time.Time) string {
389316 if t == nil {
390317 return ""
M internal/adapters/in/cli/backup_volume_test.go +13 -63
@@ -13,86 +13,36 @@ import (
1313 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1414 )
1515
16-func withBackupControlPlane(t *testing.T, plane ControlPlane) {
16+// backupRunPlane builds the control plane double the runner functions
17+// take directly: no resolver indirection is needed.
18+func backupRunPlane(t *testing.T) *climocks.MockControlPlane {
1719 t.Helper()
18- old := backupResolveControlPlane
19- backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) {
20- return &controlPlaneHandle{plane: plane}, nil
21- }
22- t.Cleanup(func() { backupResolveControlPlane = old })
23-}
24-
25-func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26- plane := climocks.NewMockControlPlane(t)
27- old := backupResolveControlPlane
28- var gotDomain string
29- backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30- gotDomain = domainName
31- return &controlPlaneHandle{plane: plane}, nil
32- }
33- t.Cleanup(func() { backupResolveControlPlane = old })
34-
35- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36-
37- cmd := newVolumeBackupRunCmd()
38- cmd.SetArgs([]string{"app.example.com"})
39-
40- require.NoError(t, cmd.ExecuteContext(context.Background()))
41- require.Equal(t, "app.example.com", gotDomain)
42-}
43-
44-func TestVolumeBackupRun_JSONFlag(t *testing.T) {
45- plane := climocks.NewMockControlPlane(t)
46- withBackupControlPlane(t, plane)
47-
48- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
49- Status: "ok",
50- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
51- }, nil)
52-
53- cmd := newVolumeBackupRunCmd()
54- var out bytes.Buffer
55- cmd.SetOut(&out)
56- cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
57-
58- require.NoError(t, cmd.ExecuteContext(context.Background()))
59- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
20+ return climocks.NewMockControlPlane(t)
6021 }
6122
6223 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
63- plane := climocks.NewMockControlPlane(t)
64- withBackupControlPlane(t, plane)
24+ plane := backupRunPlane(t)
6525
66- plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)
26+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "running"}}, nil)
6727
68- cmd := newVolumeBackupStatusCmd()
6928 var out bytes.Buffer
70- cmd.SetOut(&out)
71- cmd.SetArgs([]string{"--json"})
72-
73- require.NoError(t, cmd.ExecuteContext(context.Background()))
74- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
29+ require.NoError(t, runVolumeBackupStatus(context.Background(), plane, &out, true))
30+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"running","size_bytes":0}]`, out.String())
7531 }
7632
7733 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
78- plane := climocks.NewMockControlPlane(t)
79- withBackupControlPlane(t, plane)
34+ plane := backupRunPlane(t)
8035
8136 runErr := errors.New("one backup failed")
82- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{
37+ plane.EXPECT().RunVolumeBackups(mock.Anything, "shop", "api", "data").Return(&dto.VolumeBackupRunResponse{
8338 Status: "partial",
84- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
39+ Backups: []dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
8540 Error: runErr.Error(),
8641 }, runErr)
8742
88- cmd := newVolumeBackupRunCmd()
89- cmd.SilenceUsage = true
9043 var out bytes.Buffer
91- cmd.SetOut(&out)
92- cmd.SetArgs([]string{"app.example.com", "--json"})
93-
94- err := cmd.ExecuteContext(context.Background())
44+ err := runVolumeBackupRun(context.Background(), plane, "shop", "api", "data", &out, true)
9545 require.Error(t, err)
9646 require.ErrorIs(t, err, runErr)
97- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
47+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"completed","size_bytes":0}]`, out.String())
9848 }
D internal/adapters/in/cli/bootstrap.go +0 -136
@@ -1,136 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8- "strings"
9-
10- "github.com/spf13/cobra"
11-
12- "github.com/bnema/gordon/internal/adapters/dto"
13-)
14-
15-func newBootstrapCmd() *cobra.Command {
16- var attachments []string
17- var envPairs []string
18- var attachmentEnvPairs []string
19- var configPath string
20-
21- cmd := &cobra.Command{
22- Use: "bootstrap <domain> <image>",
23- Short: "Create a route, attachments, and secrets together",
24- Args: cobra.ExactArgs(2),
25- RunE: func(cmd *cobra.Command, args []string) error {
26- req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs)
27- if err != nil {
28- return err
29- }
30- return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout())
31- },
32- }
33-
34- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
35- cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
36- cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
37- cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
38-
39- return cmd
40-}
41-
42-func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) {
43- req := dto.BootstrapRequest{
44- Domain: args[0],
45- Image: args[1],
46- Attachments: append([]string(nil), attachments...),
47- Env: map[string]string{},
48- AttachmentEnv: map[string]map[string]string{},
49- }
50-
51- for _, pair := range envPairs {
52- parts := strings.SplitN(pair, "=", 2)
53- if len(parts) != 2 {
54- return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
55- }
56- req.Env[parts[0]] = parts[1]
57- }
58-
59- for _, pair := range attachmentEnvPairs {
60- serviceAndKV := strings.SplitN(pair, ":", 2)
61- if len(serviceAndKV) != 2 {
62- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
63- }
64- keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
65- if len(keyValue) != 2 {
66- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
67- }
68- service := serviceAndKV[0]
69- if req.AttachmentEnv[service] == nil {
70- req.AttachmentEnv[service] = map[string]string{}
71- }
72- req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
73- }
74-
75- if len(req.Env) == 0 {
76- req.Env = nil
77- }
78- if len(req.AttachmentEnv) == 0 {
79- req.AttachmentEnv = nil
80- }
81-
82- return req, nil
83-}
84-
85-func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error {
86- handle, err := resolveControlPlane(configPath)
87- if err != nil {
88- return err
89- }
90- defer handle.close()
91-
92- resp, err := handle.plane.Bootstrap(ctx, req)
93- if resp != nil {
94- if writeErr := printBootstrapSummary(out, resp); writeErr != nil {
95- return writeErr
96- }
97- }
98- if err != nil {
99- return fmt.Errorf("failed to bootstrap: %w", err)
100- }
101- if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
102- return err
103- }
104- nextImage := req.Image
105- if resp != nil && resp.Image != "" {
106- nextImage = resp.Image
107- }
108- return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage))
109-}
110-
111-func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error {
112- if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
113- return err
114- }
115- if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
116- return err
117- }
118- if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
119- return err
120- }
121- for _, step := range resp.Steps {
122- if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
123- return err
124- }
125- }
126- if len(resp.Warnings) > 0 {
127- sorted := append([]string(nil), resp.Warnings...)
128- sort.Strings(sorted)
129- for _, warning := range sorted {
130- if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
131- return err
132- }
133- }
134- }
135- return nil
136-}
M internal/adapters/in/cli/ca.go +1 -1
@@ -96,7 +96,7 @@ func newCAInfoCmd() *cobra.Command {
9696 }
9797
9898 func resolveCADataDir() (string, error) {
99- local, err := GetLocalServices(configPath)
99+ local, err := GetLocalServices(cliConfigPath)
100100 if err != nil {
101101 return "", err
102102 }
M internal/adapters/in/cli/config.go +5 -41
@@ -17,7 +17,7 @@ func newConfigCmd() *cobra.Command {
1717 Short: "Inspect Gordon configuration",
1818 }
1919
20- cmd.AddCommand(newConfigShowCmd())
20+ cmd.AddCommand(newConfigShowCmd(), newConfigValidateCmd())
2121
2222 return cmd
2323 }
@@ -32,12 +32,12 @@ func newConfigShowCmd() *cobra.Command {
3232 auto-route, network isolation, routes, and external routes.
3333
3434 Examples:
35- gordon config show
36- gordon config show --json
37- gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
35+ gordon daemon config show
36+ gordon daemon config show --json
37+ gordon daemon config show --remote prod`,
3838 RunE: func(cmd *cobra.Command, _ []string) error {
3939 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)
40+ handle, err := resolveControlPlane(cliConfigPath)
4141 if err != nil {
4242 return err
4343 }
@@ -72,9 +72,6 @@ func renderConfigTable(out io.Writer, config *remote.Config) error {
7272 if err := renderConfigSummary(out, config); err != nil {
7373 return err
7474 }
75- if err := renderConfigRoutes(out, config); err != nil {
76- return err
77- }
7875 return renderConfigExternalRoutes(out, config)
7976 }
8077
@@ -99,9 +96,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
9996 return err
10097 }
10198 }
102- if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
103- return err
104- }
10599 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
106100 return err
107101 }
@@ -113,36 +107,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
113107 return nil
114108 }
115109
116-func renderConfigRoutes(out io.Writer, config *remote.Config) error {
117- if err := cliWriteLine(out, ""); err != nil {
118- return err
119- }
120- if len(config.Routes) == 0 {
121- if err := cliWriteLine(out, cliRenderMuted("No routes configured")); err != nil {
122- return err
123- }
124- } else {
125- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
126- return err
127- }
128- rows := make([][]string, 0, len(config.Routes))
129- for _, route := range config.Routes {
130- rows = append(rows, []string{route.Domain, route.Image})
131- }
132- table := components.NewTable(
133- components.WithColumns([]components.TableColumn{
134- {Title: "Domain", Width: 30},
135- {Title: "Image", Width: 45},
136- }),
137- components.WithRows(rows),
138- )
139- if err := cliWriteLine(out, table.View()); err != nil {
140- return err
141- }
142- }
143- return nil
144-}
145-
146110 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
147111 if err := cliWriteLine(out, ""); err != nil {
148112 return err
A internal/adapters/in/cli/config_validate.go +60 -0
@@ -0,0 +1,60 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/gordon/internal/app"
10+)
11+
12+type configDiagnostic struct {
13+ Code string `json:"code"`
14+ Key string `json:"key"`
15+ Message string `json:"message"`
16+}
17+
18+type configValidationResult struct {
19+ Valid bool `json:"valid"`
20+ Diagnostics []configDiagnostic `json:"diagnostics"`
21+ Scope string `json:"scope"`
22+}
23+
24+func newConfigValidateCmd() *cobra.Command {
25+ var file string
26+ var jsonOut bool
27+ cmd := &cobra.Command{
28+ Use: "validate",
29+ Short: "Statically validate a local configuration file",
30+ Args: cobra.NoArgs,
31+ RunE: func(cmd *cobra.Command, _ []string) error {
32+ if remoteFlag != "" {
33+ return fmt.Errorf("daemon config validate is local-only; --remote is not supported")
34+ }
35+ return runConfigValidate(cmd.OutOrStdout(), file, jsonOut)
36+ },
37+ }
38+ cmd.Flags().StringVar(&file, "file", "", "Local candidate configuration file (required)")
39+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
40+ _ = cmd.MarkFlagRequired("file")
41+ return cmd
42+}
43+
44+func runConfigValidate(out io.Writer, file string, jsonOut bool) error {
45+ result := configValidationResult{Valid: true, Diagnostics: []configDiagnostic{}, Scope: "static"}
46+ if err := app.ValidateConfigFile(file); err != nil {
47+ result.Valid = false
48+ result.Diagnostics = []configDiagnostic{{Code: "config-invalid", Message: "configuration failed static validation"}}
49+ if jsonOut {
50+ if writeErr := writeJSON(out, result); writeErr != nil {
51+ return writeErr
52+ }
53+ }
54+ return fmt.Errorf("configuration failed static validation: %w", err)
55+ }
56+ if jsonOut {
57+ return writeJSON(out, result)
58+ }
59+ return cliWriteLine(out, cliRenderSuccess("Configuration is statically valid; runtime, ACTIVE-state, secret, pull, and listener checks were not performed."))
60+}
M internal/adapters/in/cli/controlplane.go +23 -38
@@ -9,35 +9,24 @@ import (
99 )
1010
1111 // ControlPlane defines command operations available to CLI execution paths.
12-//
13-// Remote implementations call admin HTTP APIs.
14-// Local implementations call services directly in-process.
12+// *remote.Client implements it for both the explicit remote and the
13+// owner-only local admin socket; tests substitute the generated mock.
1514 type ControlPlane interface {
16- ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
17- GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
18- GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19- FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)
20- AddRoute(ctx context.Context, route domain.Route) error
21- UpdateRoute(ctx context.Context, route domain.Route) error
22- RemoveRoute(ctx context.Context, routeDomain string) error
23- Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)
24-
25- ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
26- SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
27- 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
15+ // App lifecycle and reads. App mutations are daemon-owned for both
16+ // the explicit remote and the owner-only local admin socket.
17+ ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)
18+ ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error)
19+ ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error)
20+ DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error)
21+ DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
22+ StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
23+ StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
24+ RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error)
25+ RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
26+ OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
27+ ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
28+ SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
29+ DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
4130
4231 GetStatus(ctx context.Context) (*remote.Status, error)
4332 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
@@ -45,24 +34,20 @@ type ControlPlane interface {
4534 Reload(ctx context.Context) error
4635 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4736 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)
5137 ListTags(ctx context.Context, repository string) ([]string, error)
5238
53- ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)
39+ ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error)
5440 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
55- RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
56- DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
57- ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
41+ RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error)
42+ ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)
5843 VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)
59- RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error)
44+ RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error)
6045
6146 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
62- GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
6347 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
64- StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
6548
6649 ListVolumes(ctx context.Context) ([]dto.Volume, error)
6750 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
6851 }
52+
53+var _ ControlPlane = (*remote.Client)(nil)
R internal/adapters/in/cli/controlplane_remote_test.go → internal/adapters/in/cli/controlplane_client_test.go +6 -16 renamed, 62% similar
@@ -15,49 +15,39 @@ import (
1515 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1616 )
1717
18-var _ ControlPlane = (*remoteControlPlane)(nil)
19-
20-func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
21- client := remote.NewClient("https://gordon.example.com")
22- if NewRemoteControlPlane(client) == nil {
23- t.Fatal("expected non-nil remote control-plane")
24- }
25-}
26-
2718 func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {
2819 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2920 w.Header().Set("Content-Type", "application/json")
3021 w.WriteHeader(http.StatusPartialContent)
3122 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
3223 Status: "partial",
33- Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}},
24+ Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data"}},
3425 Error: "one volume failed",
3526 }))
3627 }))
3728 t.Cleanup(server.Close)
3829
39- cp := NewRemoteControlPlane(remote.NewClient(server.URL))
40- result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")
30+ cp := remote.NewClient(server.URL)
31+ result, err := cp.RunVolumeBackups(context.Background(), "shop", "api", "data")
4132
4233 require.Error(t, err)
43- assert.Contains(t, err.Error(), "run volume backups")
34+ assert.Contains(t, err.Error(), "one volume failed")
4435 require.NotNil(t, result)
4536 assert.Equal(t, "partial", result.Status)
4637 require.Len(t, result.Backups, 1)
4738 assert.Equal(t, "v1", result.Backups[0].ID)
4839 }
4940
50-func TestRemoteControlPlane_VolumeBackupErrorsAreWrapped(t *testing.T) {
41+func TestRemoteControlPlane_VolumeBackupErrorsKeepHTTPStatus(t *testing.T) {
5142 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5243 http.Error(w, "boom", http.StatusBadGateway)
5344 }))
5445 t.Cleanup(server.Close)
5546
56- cp := NewRemoteControlPlane(remote.NewClient(server.URL))
47+ cp := remote.NewClient(server.URL)
5748 _, err := cp.VolumeBackupStatus(context.Background())
5849
5950 require.Error(t, err)
60- assert.Contains(t, err.Error(), "get volume backup status")
6151 var httpErr *remote.HTTPError
6252 require.True(t, errors.As(err, &httpErr))
6353 assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)
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-}
D internal/adapters/in/cli/controlplane_remote.go +0 -223
@@ -1,223 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6-
7- "github.com/bnema/gordon/internal/adapters/dto"
8- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9- "github.com/bnema/gordon/internal/domain"
10-)
11-
12-type remoteControlPlane struct {
13- client *remote.Client
14-}
15-
16-func NewRemoteControlPlane(client *remote.Client) ControlPlane {
17- return &remoteControlPlane{client: client}
18-}
19-
20-func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
21- return r.client.ListRoutesWithDetails(ctx)
22-}
23-
24-func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
25- return r.client.GetHealth(ctx)
26-}
27-
28-func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
29- return r.client.GetRoute(ctx, routeDomain)
30-}
31-
32-func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
33- return r.client.FindRoutesByImage(ctx, imageName)
34-}
35-
36-func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
37- return r.client.AddRoute(ctx, route)
38-}
39-
40-func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
41- return r.client.UpdateRoute(ctx, route)
42-}
43-
44-func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
45- _, err := r.RemoveRouteWithCleanup(ctx, routeDomain)
46- return err
47-}
48-
49-func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
50- return r.client.RemoveRouteWithCleanup(ctx, routeDomain)
51-}
52-
53-func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
54- return r.client.GetRouteCleanupPreview(ctx, routeDomain)
55-}
56-
57-func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
58- return r.client.Bootstrap(ctx, req)
59-}
60-
61-func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
62- return r.client.ListSecretsWithAttachments(ctx, secretDomain)
63-}
64-
65-func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
66- return r.client.SetSecrets(ctx, secretDomain, secrets)
67-}
68-
69-func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
70- return r.client.DeleteSecret(ctx, secretDomain, key)
71-}
72-
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) {
122- return r.client.GetStatus(ctx)
123-}
124-
125-func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
126- return r.client.GetTLSStatus(ctx)
127-}
128-
129-func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
130- return r.client.GetTrafficStatus(ctx)
131-}
132-
133-func (r *remoteControlPlane) Reload(ctx context.Context) error {
134- return r.client.Reload(ctx)
135-}
136-
137-func (r *remoteControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
138- return r.client.ListNetworks(ctx)
139-}
140-
141-func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
142- return r.client.GetConfig(ctx)
143-}
144-
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) {
158- return r.client.ListTags(ctx, repository)
159-}
160-
161-func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
162- return r.client.ListBackups(ctx, backupDomain)
163-}
164-
165-func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
166- return r.client.BackupStatus(ctx)
167-}
168-
169-func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
170- return r.client.RunBackup(ctx, backupDomain, dbName)
171-}
172-
173-func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
174- return r.client.DetectDatabases(ctx, backupDomain)
175-}
176-
177-func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
178- jobs, err := r.client.ListVolumeBackups(ctx, backupDomain)
179- if err != nil {
180- return nil, fmt.Errorf("list volume backups: %w", err)
181- }
182- return jobs, nil
183-}
184-
185-func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
186- jobs, err := r.client.VolumeBackupStatus(ctx)
187- if err != nil {
188- return nil, fmt.Errorf("get volume backup status: %w", err)
189- }
190- return jobs, nil
191-}
192-
193-func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
194- result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
195- if err != nil {
196- return result, fmt.Errorf("run volume backups: %w", err)
197- }
198- return result, nil
199-}
200-
201-func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
202- return r.client.GetProcessLogs(ctx, lines)
203-}
204-
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) {
210- return r.client.StreamProcessLogs(ctx, lines)
211-}
212-
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) {
218- return r.client.ListVolumes(ctx)
219-}
220-
221-func (r *remoteControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
222- return r.client.PruneVolumes(ctx, req)
223-}
M internal/adapters/in/cli/controlplane_resolver.go +19 -39
@@ -5,7 +5,6 @@ import (
55 "fmt"
66
77 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8- "github.com/bnema/gordon/internal/app"
98 )
109
1110 type controlPlaneHandle struct {
@@ -24,39 +23,32 @@ func (h *controlPlaneHandle) close() {
2423 }
2524 }
2625
27-func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
26+var newLocalControlPlaneClient = remote.NewLocalClient
27+
28+func resolveDaemonClient() (*remote.Client, bool, error) {
2829 client, isRemote, err := GetRemoteClient()
29- if err != nil {
30- return nil, err
30+ if err != nil || isRemote {
31+ return client, isRemote, err
3132 }
32- if isRemote {
33- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
33+
34+ client, err = newLocalControlPlaneClient()
35+ if err != nil {
36+ return nil, false, fmt.Errorf("local control plane unavailable: %w", err)
3437 }
38+ return client, false, nil
39+}
3540
36- return resolveLocalControlPlane(configPath)
41+func resolveControlPlane(_ string) (*controlPlaneHandle, error) {
42+ client, isRemote, err := resolveDaemonClient()
43+ if err != nil {
44+ return nil, err
45+ }
46+ return &controlPlaneHandle{plane: client, isRemote: isRemote}, nil
3747 }
3848
3949 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {
4050 client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...)
41- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}
42-}
43-
44-func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
45- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
46- return inferRemoteForRouteDomain(ctx, routeDomain)
47- })
48-}
49-
50-func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
51- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
52- return inferRemoteForRouteCleanupDomain(ctx, routeDomain)
53- })
54-}
55-
56-func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) {
57- return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
58- return inferRemoteForAttachmentTarget(ctx, target)
59- })
51+ return &controlPlaneHandle{plane: client, isRemote: true}
6052 }
6153
6254 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
@@ -73,17 +65,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co
7365 if resolved != nil {
7466 return newRemoteControlPlaneHandle(resolved), nil
7567 }
76- return resolveControlPlane(configPath)
77-}
78-
79-func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
80- kernel, err := app.NewKernelQuiet(configPath)
81- if err != nil {
82- return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
83- }
84-
85- return &controlPlaneHandle{
86- plane: NewLocalControlPlane(kernel),
87- closeFn: kernel.Close,
88- }, nil
68+ return resolveControlPlane(cliConfigPath)
8969 }
M internal/adapters/in/cli/controlplane_resolver_test.go +94 -70
@@ -1,110 +1,134 @@
11 package cli
22
33 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
48 "os"
59 "path/filepath"
610 "testing"
11+ "time"
712
13+ "github.com/stretchr/testify/assert"
814 "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/localadmin"
918 )
1019
11-func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
12- tmpDir := t.TempDir()
13- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
20+func isolateControlPlaneResolver(t *testing.T) {
21+ t.Helper()
22+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
1423 t.Setenv("GORDON_REMOTE", "")
1524 t.Setenv("GORDON_TOKEN", "")
1625 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
26+ originalFactory := newLocalControlPlaneClient
1727 t.Cleanup(func() {
1828 remoteFlag = originalRemoteFlag
1929 tokenFlag = originalTokenFlag
2030 insecureTLSFlag = originalInsecureTLSFlag
31+ newLocalControlPlaneClient = originalFactory
2132 })
22-
2333 remoteFlag = ""
2434 tokenFlag = ""
2535 insecureTLSFlag = false
36+}
2637
27- configPath := filepath.Join(tmpDir, "gordon.toml")
28- err := os.WriteFile(configPath, []byte(`[server]
29-gordon_domain = "gordon.local"
30-data_dir = "`+filepath.Join(tmpDir, "data")+`"
31-
32-[auth]
33-enabled = true
34-secrets_backend = "unsafe"
35-`), 0o600)
38+func localControlPlaneTestClient(t *testing.T, handler http.Handler) *remote.Client {
39+ t.Helper()
40+ dir := t.TempDir()
41+ listener, _, err := localadmin.Listen(dir)
3642 require.NoError(t, err)
43+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
44+ go func() { _ = server.Serve(listener) }()
45+ t.Cleanup(func() { _ = server.Close() })
46+ return remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
47+}
3748
38- handle, err := resolveControlPlane(configPath)
49+func TestResolveControlPlane_LocalUsesSocketBackedRemotePlane(t *testing.T) {
50+ isolateControlPlaneResolver(t)
51+ requests := make(chan string, 1)
52+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ requests <- r.URL.Path
54+ w.Header().Set("Content-Type", "application/json")
55+ _ = json.NewEncoder(w).Encode(remote.Status{RegistryDomain: "socket.local"})
56+ }))
57+ factoryCalls := 0
58+ newLocalControlPlaneClient = func() (*remote.Client, error) {
59+ factoryCalls++
60+ return client, nil
61+ }
62+
63+ // A deliberately unusable config path proves local resolution does not open a kernel/store.
64+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "missing", "gordon.toml"))
3965 require.NoError(t, err)
4066 require.NotNil(t, handle)
41- require.NotNil(t, handle.plane)
42- defer handle.close()
43-}
67+ assert.False(t, handle.isRemote)
68+ assert.IsType(t, &remote.Client{}, handle.plane)
69+ assert.Equal(t, 1, factoryCalls)
4470
45-func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
46- tmpDir := t.TempDir()
47- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
48- t.Setenv("GORDON_REMOTE", "")
49- t.Setenv("GORDON_TOKEN", "")
50- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
51- t.Cleanup(func() {
52- remoteFlag = originalRemoteFlag
53- tokenFlag = originalTokenFlag
54- insecureTLSFlag = originalInsecureTLSFlag
55- })
71+ status, err := handle.plane.GetStatus(context.Background())
72+ require.NoError(t, err)
73+ assert.Equal(t, "socket.local", status.RegistryDomain)
74+ select {
75+ case path := <-requests:
76+ assert.Equal(t, "/admin/status", path)
77+ case <-time.After(2 * time.Second):
78+ t.Fatal("socket server received no request")
79+ }
80+}
5681
57- remoteFlag = ""
58- tokenFlag = ""
59- insecureTLSFlag = false
82+func TestResolveControlPlane_LocalFailsClosedWhenDaemonUnavailable(t *testing.T) {
83+ isolateControlPlaneResolver(t)
84+ newLocalControlPlaneClient = func() (*remote.Client, error) {
85+ return nil, remote.ErrDaemonUnavailable
86+ }
6087
61- configPath := filepath.Join(tmpDir, "gordon.toml")
62- err := os.WriteFile(configPath, []byte(`[server]
63-gordon_domain = "gordon.local"
64-data_dir = "`+filepath.Join(tmpDir, "data")+`"
88+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "would-create-store.toml"))
89+ require.Error(t, err)
90+ assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
91+ assert.Nil(t, handle)
92+}
6593
66-[auth]
67-enabled = false
68-secrets_backend = "unsafe"
69-`), 0o600)
70- require.NoError(t, err)
94+func TestResolveControlPlane_ExplicitRemoteDoesNotUseLocalFactory(t *testing.T) {
95+ isolateControlPlaneResolver(t)
96+ remoteFlag = "https://example.invalid"
97+ tokenFlag = "token"
98+ newLocalControlPlaneClient = func() (*remote.Client, error) {
99+ t.Fatal("local factory invoked for explicit remote")
100+ return nil, errors.New("unreachable")
101+ }
71102
72- handle, err := resolveControlPlane(configPath)
103+ handle, err := resolveControlPlane("")
73104 require.NoError(t, err)
74- require.NotNil(t, handle)
75- require.NotNil(t, handle.plane)
76- defer handle.close()
105+ assert.True(t, handle.isRemote)
106+ assert.IsType(t, &remote.Client{}, handle.plane)
77107 }
78108
79109 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80- tmpDir := t.TempDir()
81- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
82- t.Setenv("GORDON_REMOTE", "")
83- t.Setenv("GORDON_TOKEN", "")
84- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
85- t.Cleanup(func() {
86- remoteFlag = originalRemoteFlag
87- tokenFlag = originalTokenFlag
88- insecureTLSFlag = originalInsecureTLSFlag
89- })
90-
110+ isolateControlPlaneResolver(t)
111+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
91112 remoteFlag = "does-not-exist"
92- tokenFlag = ""
93- insecureTLSFlag = false
94113
95- configPath := filepath.Join(tmpDir, "gordon.toml")
96- err := os.WriteFile(configPath, []byte(`[server]
97-gordon_domain = "gordon.local"
98-data_dir = "`+filepath.Join(tmpDir, "data")+`"
114+ handle, err := resolveControlPlane("")
115+ require.Error(t, err)
116+ assert.Nil(t, handle)
117+ assert.Contains(t, err.Error(), "does-not-exist")
118+}
99119
100-[auth]
101-enabled = false
102-secrets_backend = "unsafe"
103-`), 0o600)
120+func TestResolveControlPlane_LocalIgnoresConfigAndStoreState(t *testing.T) {
121+ isolateControlPlaneResolver(t)
122+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
123+ w.Header().Set("Content-Type", "application/json")
124+ _ = json.NewEncoder(w).Encode(remote.Status{})
125+ }))
126+ newLocalControlPlaneClient = func() (*remote.Client, error) { return client, nil }
127+
128+ lockedStore := filepath.Join(t.TempDir(), "state.db")
129+ require.NoError(t, os.WriteFile(lockedStore, []byte("active daemon store"), 0o600))
130+ handle, err := resolveControlPlane(lockedStore)
131+ require.NoError(t, err)
132+ _, err = handle.plane.GetStatus(context.Background())
104133 require.NoError(t, err)
105-
106- handle, err := resolveControlPlane(configPath)
107- require.Error(t, err)
108- require.Nil(t, handle)
109- require.Contains(t, err.Error(), "does-not-exist")
110134 }
A internal/adapters/in/cli/daemon.go +29 -0
@@ -0,0 +1,29 @@
1+package cli
2+
3+import "github.com/spf13/cobra"
4+
5+// newDaemonCmd groups commands that inspect or operate the Gordon daemon
6+// itself rather than the apps it runs. They target the local daemon or the
7+// one selected with --remote.
8+func newDaemonCmd() *cobra.Command {
9+ cmd := &cobra.Command{
10+ Use: "daemon",
11+ Short: "Inspect and operate the Gordon daemon",
12+ Long: `Inspect and operate the Gordon daemon: status, process logs,
13+configuration, TLS, traffic, and networks.
14+
15+Targets the local daemon, or the one selected with --remote.`,
16+ }
17+
18+ cmd.AddCommand(
19+ newStatusCmd(),
20+ newLogsCmd(),
21+ newReloadCmd(),
22+ newConfigCmd(),
23+ newTLSCmd(),
24+ newTrafficCmd(),
25+ newNetworksCmd(),
26+ )
27+
28+ return cmd
29+}
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 +40 -41
@@ -69,12 +69,11 @@ var imagesListTableColumns = []components.TableColumn{
6969 func newImagesCmd() *cobra.Command {
7070 cmd := &cobra.Command{
7171 Use: "images",
72- Short: "List and prune images",
73- Long: `Inspect and clean up runtime and registry images.
74-
75-These commands currently require remote mode with a configured target.`,
72+ Short: "Push, list, and prune images",
73+ Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`,
7674 }
7775
76+ cmd.AddCommand(newPushCmd())
7877 cmd.AddCommand(newImagesListCmd())
7978 cmd.AddCommand(newImagesPruneCmd())
8079 cmd.AddCommand(newImagesTagsCmd())
@@ -89,14 +88,10 @@ func newImagesListCmd() *cobra.Command {
8988 Use: "list",
9089 Short: "List runtime images and registry tags",
9190 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote, err := GetRemoteClient()
91+ client, _, err := resolveDaemonClient()
9392 if err != nil {
9493 return err
9594 }
96- if !isRemote {
97- return fmt.Errorf("images commands require a configured remote target")
98- }
99-
10095 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
10196 },
10297 }
@@ -115,21 +110,21 @@ func newImagesPruneCmd() *cobra.Command {
115110 Long: `Remove dangling runtime images and/or old registry tags.
116111
117112 By default both runtime and registry cleanup run, keeping latest + 3 previous
118-release tags per repository. Use --dangling or --registry to restrict scope.`,
113+release tags per repository. Use --dangling or --registry to restrict scope.
114+
115+Only resources proven safe are deleted: every candidate gets an eligible,
116+protected, or unknown verdict, and protected or unknown candidates are
117+reported and left in place. A prune that deletes nothing succeeds.`,
119118 RunE: func(cmd *cobra.Command, _ []string) error {
120- client, isRemote, err := GetRemoteClient()
119+ client, _, err := resolveDaemonClient()
121120 if err != nil {
122121 return err
123122 }
124- if !isRemote {
125- return fmt.Errorf("images commands require a configured remote target")
126- }
127-
128123 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
129124 },
130125 }
131126
132- cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
127+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Run the full inventory and planning, then report without deleting")
133128 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
134129 "Number of previous non-latest tags to keep per repository (latest is always kept)")
135130 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
@@ -150,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command {
150145 Examples:
151146 gordon images tags myapp
152147 gordon images tags myapp --json
153- gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,
148+ gordon images tags myapp --remote prod`,
154149 Args: cobra.ExactArgs(1),
155150 RunE: func(cmd *cobra.Command, args []string) error {
156151 ctx := cmd.Context()
@@ -357,7 +352,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
357352 }
358353 }
359354
360- req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
355+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, false)
361356
362357 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
363358 if err != nil {
@@ -378,12 +373,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
378373 }
379374
380375 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))
376+ 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 {
377+ return err
378+ }
379+ } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil {
382380 return err
383381 }
384382
385- err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
386- return err
383+ return cliRenderPruneSummary(out, resp.Plan)
387384 }
388385
389386 func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {
@@ -391,33 +388,34 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP
391388 return err
392389 }
393390
394- if pruneDangling {
395- images, err := client.ListImages(ctx)
396- if err != nil {
397- return fmt.Errorf("failed to list images: %w", err)
398- }
399-
400- danglingCount := 0
401- for _, img := range images {
402- if img.Dangling {
403- danglingCount++
404- }
405- }
391+ // A dry run executes the full inventory and planning path: only the
392+ // deletion step is skipped, so the report is the executed plan.
393+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true)
394+ resp, err := client.PruneImages(ctx, req)
395+ if err != nil {
396+ return fmt.Errorf("failed to plan image prune: %w", err)
397+ }
398+ if resp == nil {
399+ return fmt.Errorf("failed to plan image prune: empty response")
400+ }
406401
407- if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
408- return err
409- }
410- } else {
411- if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
402+ if pruneDangling {
403+ if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil {
412404 return err
413405 }
406+ } else if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
407+ return err
414408 }
415409
416410 if pruneRegistry {
417- return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases)
411+ if err := cliWritef(out, "Registry: latest + %d previous tags per repository\n", opts.KeepReleases); err != nil {
412+ return err
413+ }
414+ } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil {
415+ return err
418416 }
419417
420- return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
418+ return cliRenderPruneSummary(out, resp.Plan)
421419 }
422420
423421 func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {
@@ -561,11 +559,12 @@ func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]
561559 return kept
562560 }
563561
564-func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
562+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry, dryRun bool) dto.ImagePruneRequest {
565563 return dto.ImagePruneRequest{
566564 KeepLast: &keepReleases,
567565 PruneDangling: &pruneDangling,
568566 PruneRegistry: &pruneRegistry,
567+ DryRun: &dryRun,
569568 }
570569 }
571570
M internal/adapters/in/cli/images_test.go +35 -12
@@ -297,7 +297,18 @@ func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
297297
298298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299299 client := &imagesClientMock{
300- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
300+ pruneResp: &dto.ImagePruneResponse{
301+ Plan: dto.PruneSummary{
302+ Applied: false,
303+ Eligible: 2,
304+ Protected: 1,
305+ Candidates: []dto.PruneCandidate{
306+ {Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"},
307+ {Kind: "runtime-image", Ref: "sha256:b", Verdict: "eligible"},
308+ {Kind: "registry-tag", Ref: "app:v1", Verdict: "protected", Reasons: []string{"protected-active-service"}},
309+ },
310+ },
311+ },
301312 }
302313
303314 var out bytes.Buffer
@@ -307,15 +318,22 @@ func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
307318
308319 text := out.String()
309320 assert.Contains(t, text, "Dry run")
310- assert.Contains(t, text, "would prune 2 dangling runtime images")
311- assert.Contains(t, text, "would keep latest + 3 previous tags")
312- assert.Equal(t, 1, client.listImagesCalls)
313- assert.Equal(t, 0, client.pruneCalls)
321+ assert.Contains(t, text, "would delete 2 images")
322+ assert.Contains(t, text, "latest + 3 previous tags")
323+ assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
324+ assert.Contains(t, text, "protected-active-service")
325+ assert.Equal(t, 1, client.pruneCalls)
326+ assert.NotNil(t, client.lastPruneOpts.DryRun)
327+ assert.True(t, *client.lastPruneOpts.DryRun)
314328 }
315329
316330 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317331 client := &imagesClientMock{
318- listImagesResp: []dto.Image{{Dangling: true}},
332+ pruneResp: &dto.ImagePruneResponse{
333+ Plan: dto.PruneSummary{
334+ Candidates: []dto.PruneCandidate{{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"}},
335+ },
336+ },
319337 }
320338
321339 var out bytes.Buffer
@@ -324,14 +342,18 @@ func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
324342 require.NoError(t, err)
325343
326344 text := out.String()
327- assert.Contains(t, text, "would prune 1 dangling runtime images")
345+ assert.Contains(t, text, "would delete 1 images")
328346 assert.Contains(t, text, "Registry cleanup skipped")
329- assert.Equal(t, 0, client.pruneCalls)
347+ assert.Equal(t, 1, client.pruneCalls)
330348 }
331349
332350 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333351 client := &imagesClientMock{
334- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
352+ pruneResp: &dto.ImagePruneResponse{
353+ Plan: dto.PruneSummary{
354+ Candidates: []dto.PruneCandidate{{Kind: "registry-tag", Ref: "app:v1", Verdict: "eligible"}},
355+ },
356+ },
335357 }
336358
337359 var out bytes.Buffer
@@ -341,8 +363,8 @@ func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
341363
342364 text := out.String()
343365 assert.Contains(t, text, "Runtime cleanup skipped")
344- assert.Contains(t, text, "would keep latest + 5 previous tags")
345- assert.Equal(t, 0, client.pruneCalls)
366+ assert.Contains(t, text, "latest + 5 previous tags")
367+ assert.Equal(t, 1, client.pruneCalls)
346368 }
347369
348370 // ---------------------------------------------------------------------------
@@ -484,7 +506,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
484506 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485507
486508 client := &imagesClientMock{
487- listImagesResp: []dto.Image{{Dangling: true}},
509+ pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
488510 }
489511
490512 var out bytes.Buffer
@@ -493,6 +515,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
493515 require.NoError(t, err)
494516
495517 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518+ assert.Equal(t, 1, client.pruneCalls)
496519 }
497520
498521 // ---------------------------------------------------------------------------
M internal/adapters/in/cli/json_parity_test.go +3 -9
@@ -12,10 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1212 name string
1313 builder func() *cobra.Command
1414 }{
15- {"routes list", newRoutesListCmd},
16- {"routes status", newRoutesStatusCmd},
17- {"attachments list", newAttachmentsListCmd},
18- {"secrets list", newSecretsListCmd},
1915 {"images list", newImagesListCmd},
2016 {"backup list", newBackupListCmd},
2117 {"backup volumes list", newVolumeBackupListCmd},
@@ -23,12 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2319 {"backup volumes status", newVolumeBackupStatusCmd},
2420 {"auth token list", newTokenListCmd},
2521 {"remotes list", newRemotesListCmd},
26- {"pin list", newPinListCmd},
27- {"config show", newConfigShowCmd},
28- {"routes show", newRoutesShowCmd},
29- {"networks list", newNetworksListCmd},
22+ {"daemon config show", newConfigShowCmd},
23+ {"daemon networks", newNetworksCmd},
3024 {"images tags", newImagesTagsCmd},
31- {"tls status", newTLSStatusCmd},
25+ {"daemon tls", newTLSCmd},
3226 }
3327
3428 for _, tc := range commands {
M internal/adapters/in/cli/json_test.go +4 -192
@@ -10,37 +10,9 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/gordon/internal/adapters/dto"
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1413 "github.com/bnema/gordon/internal/domain"
1514 )
1615
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) {
36- cmd := newSecretsListCmd()
37- f := cmd.Flags().Lookup("json")
38- assert.NotNil(t, f)
39- if f != nil {
40- assert.Equal(t, "false", f.DefValue)
41- }
42-}
43-
4416 func TestImagesList_JSONFlag_Accepted(t *testing.T) {
4517 cmd := newImagesListCmd()
4618 f := cmd.Flags().Lookup("json")
@@ -77,15 +49,6 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
7749 }
7850 }
7951
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-
8952 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
9053 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
9154 images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}
@@ -113,7 +76,7 @@ func TestPinList_JSONShape_RoundTripsTags(t *testing.T) {
11376
11477 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
11578 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}}
79+ jobs := []domain.BackupJob{{ID: "b1", App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, StartedAt: startedAt}}
11780
11881 payload, err := json.Marshal(jobs)
11982 require.NoError(t, err)
@@ -122,7 +85,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
12285 require.NoError(t, json.Unmarshal(payload, &got))
12386 require.Len(t, got, 1)
12487 assert.Equal(t, "b1", got[0].ID)
125- assert.Equal(t, "app.example.com", got[0].Domain)
88+ assert.Equal(t, "shop", got[0].App)
89+ assert.Equal(t, "api", got[0].Service)
90+ assert.Equal(t, "orders", got[0].DBName)
12691 }
12792
12893 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
@@ -172,159 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
172137 assert.True(t, got[0].InsecureTLS)
173138 }
174139
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) {
197- payload := struct {
198- Domain string `json:"domain"`
199- Keys []string `json:"keys"`
200- Attachments []remote.AttachmentSecrets `json:"attachments"`
201- }{
202- Domain: "app.example.com",
203- Keys: []string{"API_KEY"},
204- Attachments: []remote.AttachmentSecrets{{
205- Service: "postgres",
206- Keys: []string{"POSTGRES_PASSWORD"},
207- }},
208- }
209-
210- encoded, err := json.Marshal(payload)
211- require.NoError(t, err)
212-
213- var got struct {
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))
219- assert.Equal(t, payload.Domain, got.Domain)
220- 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-}
327-
328140 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
329141 var out bytes.Buffer
330142 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/local.go +2 -63
@@ -4,25 +4,18 @@ package cli
44 import (
55 "context"
66 "fmt"
7- "path/filepath"
87 "strings"
98
10- "github.com/bnema/zerowrap"
119 "github.com/spf13/viper"
1210
13- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
1411 "github.com/bnema/gordon/internal/app"
1512 "github.com/bnema/gordon/internal/boundaries/in"
16- "github.com/bnema/gordon/internal/boundaries/out"
17- "github.com/bnema/gordon/internal/domain"
1813 "github.com/bnema/gordon/internal/usecase/config"
19- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
2014 )
2115
2216 // LocalServices provides direct access to local services for CLI operations.
2317 type LocalServices struct {
2418 configSvc in.ConfigService
25- secretSvc in.SecretService
2619 dataDir string
2720 tlsEnabled bool
2821 }
@@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService {
3225 return l.configSvc
3326 }
3427
35-// GetSecretService returns the secret service.
36-func (l *LocalServices) GetSecretService() in.SecretService {
37- return l.secretSvc
38-}
39-
4028 // GetDataDir returns the data directory.
4129 func (l *LocalServices) GetDataDir() string {
4230 return l.dataDir
@@ -49,7 +37,7 @@ func (l *LocalServices) HasInternalTLS() bool {
4937
5038 // GetLocalServices creates local services for CLI operations.
5139 // It loads the config and initializes services without starting the server.
52-func GetLocalServices(configPath string) (*LocalServices, error) {
40+func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
5341 // Set up viper with defaults
5442 v := viper.New()
5543 v.SetDefault("server.port", 8088)
@@ -57,7 +45,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
5745 v.SetDefault("server.data_dir", app.DefaultDataDir())
5846
5947 // Configure viper with config file paths
60- app.ConfigureViper(v, configPath)
48+ app.ConfigureViper(v, cliConfigPath)
6149
6250 // Read config file
6351 if err := v.ReadInConfig(); err != nil {
@@ -67,8 +55,6 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
6755 // Config file not found is OK for some operations
6856 }
6957
70- log := zerowrap.New(cliLogConfig)
71-
7258 // Create config service (without event bus for CLI operations)
7359 configSvc := config.NewService(v, nil)
7460 ctx := context.Background()
@@ -76,27 +62,13 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
7662 return nil, fmt.Errorf("failed to load configuration: %w", err)
7763 }
7864
79- // Determine data directory and env directory
8065 dataDir := v.GetString("server.data_dir")
8166 if dataDir == "" {
8267 dataDir = app.DefaultDataDir()
8368 }
8469
85- envDir := v.GetString("env.dir")
86- if envDir == "" {
87- envDir = filepath.Join(dataDir, "env")
88- }
89-
90- // Create domain secret store using configured backend (pass or file-based)
91- domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log)
92- if err != nil {
93- return nil, fmt.Errorf("failed to create domain secret store: %w", err)
94- }
95- secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)
96-
9770 return &LocalServices{
9871 configSvc: configSvc,
99- secretSvc: secretSvc,
10072 dataDir: dataDir,
10173 tlsEnabled: hasLocalTLSCapableEntrypoint(v),
10274 }, nil
@@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
11587 }
11688 return false
11789 }
118-
119-func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
120- backend := resolveLocalSecretsBackend(v)
121- switch backend {
122- case domain.SecretsBackendPass:
123- return domainsecrets.NewPassStore(log)
124- case domain.SecretsBackendSops:
125- return nil, fmt.Errorf("sops backend not yet supported for domain secrets")
126- default:
127- return domainsecrets.NewFileStore(envDir, log)
128- }
129-}
130-
131-func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend {
132- backend := strings.TrimSpace(v.GetString("auth.secrets_backend"))
133- if backend == "" {
134- // Legacy key support for older configs.
135- backend = strings.TrimSpace(v.GetString("secrets.backend"))
136- }
137-
138- switch backend {
139- case "pass":
140- return domain.SecretsBackendPass
141- case "sops":
142- return domain.SecretsBackendSops
143- case "unsafe", "":
144- return domain.SecretsBackendUnsafe
145- default:
146- log := zerowrap.Default()
147- log.Warn().Str("backend", backend).Msg("unrecognized secrets backend, falling back to unsafe")
148- return domain.SecretsBackendUnsafe
149- }
150-}
M internal/adapters/in/cli/local_test.go +0 -66
@@ -3,41 +3,10 @@ package cli
33 import (
44 "testing"
55
6- "github.com/bnema/zerowrap"
76 "github.com/spf13/viper"
87 "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11- "github.com/bnema/gordon/internal/domain"
128 )
139
14-func TestResolveLocalSecretsBackend(t *testing.T) {
15- t.Run("auth backend takes precedence", func(t *testing.T) {
16- v := viper.New()
17- v.Set("auth.secrets_backend", "pass")
18- v.Set("secrets.backend", "unsafe")
19-
20- got := resolveLocalSecretsBackend(v)
21- require.Equal(t, domain.SecretsBackendPass, got)
22- })
23-
24- t.Run("falls back to legacy secrets backend key", func(t *testing.T) {
25- v := viper.New()
26- v.Set("secrets.backend", "pass")
27-
28- got := resolveLocalSecretsBackend(v)
29- require.Equal(t, domain.SecretsBackendPass, got)
30- })
31-
32- t.Run("defaults to unsafe for unknown backend", func(t *testing.T) {
33- v := viper.New()
34- v.Set("auth.secrets_backend", "wat")
35-
36- got := resolveLocalSecretsBackend(v)
37- require.Equal(t, domain.SecretsBackendUnsafe, got)
38- })
39-}
40-
4110 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
4211 for _, tt := range []struct {
4312 name string
@@ -56,38 +25,3 @@ func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
5625 })
5726 }
5827 }
59-
60-func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) {
61- // pass(1) must be available on the system for this test
62- if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil {
63- t.Skipf("pass store not available: %v", err)
64- }
65-
66- v := viper.New()
67- v.Set("auth.secrets_backend", "pass")
68-
69- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
70- store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
71- require.NoError(t, err)
72- require.IsType(t, &domainsecrets.PassStore{}, store)
73-}
74-
75-func TestCreateLocalDomainSecretStore_RejectsSopsBackend(t *testing.T) {
76- v := viper.New()
77- v.Set("auth.secrets_backend", "sops")
78-
79- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
80- _, err := createLocalDomainSecretStore(v, t.TempDir(), log)
81- require.Error(t, err)
82- require.Contains(t, err.Error(), "sops backend not yet supported")
83-}
84-
85-func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) {
86- v := viper.New()
87- v.Set("auth.secrets_backend", "unsafe")
88-
89- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
90- store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
91- require.NoError(t, err)
92- require.IsType(t, &domainsecrets.FileStore{}, store)
93-}
M internal/adapters/in/cli/mocks/mock_control_plane.go +575 -1508 large diff (2083 lines changed): collapsed
M internal/adapters/in/cli/mocks/mock_images_client.go +10 -1
@@ -17,10 +17,19 @@ func NewMockimagesClient(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockimagesClient {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockimagesClient{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
M internal/adapters/in/cli/mocks/mock_push_image_ops.go +10 -1
@@ -16,10 +16,19 @@ func NewMockpushImageOps(t interface {
1616 mock.TestingT
1717 Cleanup(func())
1818 }) *MockpushImageOps {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
1923 mock := &MockpushImageOps{}
2024 mock.Mock.Test(t)
2125
22- t.Cleanup(func() { mock.AssertExpectations(t) })
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
2332
2433 return mock
2534 }
M internal/adapters/in/cli/mocks/mock_volumes_client.go +10 -1
@@ -17,10 +17,19 @@ func NewMockvolumesClient(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockvolumesClient {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockvolumesClient{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
M internal/adapters/in/cli/networks.go +6 -17
@@ -12,32 +12,21 @@ import (
1212 )
1313
1414 func newNetworksCmd() *cobra.Command {
15- cmd := &cobra.Command{
16- Use: "networks",
17- Short: "Inspect Gordon-managed networks",
18- }
19-
20- cmd.AddCommand(newNetworksListCmd())
21-
22- return cmd
23-}
24-
25-func newNetworksListCmd() *cobra.Command {
2615 var jsonOut bool
2716
2817 cmd := &cobra.Command{
29- Use: "list",
30- Short: "List Gordon-managed Docker networks",
18+ Use: "networks",
19+ Short: "List Gordon-managed networks",
3120 Long: `Display Docker networks managed by Gordon, including which containers
3221 are connected to each network.
3322
3423 Examples:
35- gordon networks list
36- gordon networks list --json
37- gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
24+ gordon daemon networks
25+ gordon daemon networks --json
26+ gordon daemon networks --remote prod`,
3827 RunE: func(cmd *cobra.Command, _ []string) error {
3928 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)
29+ handle, err := resolveControlPlane(cliConfigPath)
4130 if err != nil {
4231 return err
4332 }
D internal/adapters/in/cli/parity_matrix_test.go +0 -91
@@ -1,91 +0,0 @@
1-package cli
2-
3-import (
4- "go/ast"
5- "go/parser"
6- "go/token"
7- "os"
8- "strings"
9- "testing"
10-)
11-
12-type uiAdoptionExpectation struct {
13- family string
14- file string
15- functions []string
16-}
17-
18-var uiAdoptionExpectations = []uiAdoptionExpectation{
19- {
20- family: "root/server",
21- file: "root.go",
22- functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
23- },
24- {
25- family: "backups",
26- file: "backup.go",
27- functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
28- },
29- {
30- family: "images",
31- file: "images.go",
32- functions: []string{"runImagesList", "runImagesPrune"},
33- },
34-}
35-
36-func TestLocalParityMatrix(t *testing.T) {
37- checks := []struct {
38- file string
39- legacyText string
40- command string
41- }{
42- {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
43- // Guards against reintroducing the old rollback-only remote-mode error text during the command rename.
44- {file: "pin.go", legacyText: "rollback requires remote mode", command: "gordon pin"},
45- {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
46- {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
47- {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
48- }
49-
50- for _, check := range checks {
51- content, err := os.ReadFile(check.file)
52- if err != nil {
53- t.Fatalf("failed to read %s: %v", check.file, err)
54- }
55- if strings.Contains(string(content), check.legacyText) {
56- t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file)
57- }
58- }
59-}
60-
61-func TestUIAdoptionMatrixCoverage(t *testing.T) {
62- for _, expect := range uiAdoptionExpectations {
63- t.Run(expect.family, func(t *testing.T) {
64- if len(expect.functions) == 0 {
65- t.Fatalf("ui adoption matrix for %s is empty", expect.family)
66- }
67-
68- fset := token.NewFileSet()
69- fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
70- if err != nil {
71- t.Fatalf("failed to parse %s: %v", expect.file, err)
72- }
73-
74- found := make(map[string]bool, len(expect.functions))
75- ast.Inspect(fileNode, func(n ast.Node) bool {
76- fn, ok := n.(*ast.FuncDecl)
77- if !ok {
78- return true
79- }
80- found[fn.Name.Name] = true
81- return true
82- })
83-
84- for _, fn := range expect.functions {
85- if !found[fn] {
86- t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
87- }
88- }
89- })
90- }
91-}
D internal/adapters/in/cli/pin.go +0 -249
@@ -1,249 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "slices"
8- "sort"
9- "strings"
10-
11- "github.com/spf13/cobra"
12- "golang.org/x/mod/semver"
13-
14- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16- "github.com/bnema/gordon/internal/domain"
17-)
18-
19-func newPinCmd() *cobra.Command {
20- var targetTag string
21-
22- cmd := &cobra.Command{
23- Use: "pin <domain>",
24- Short: "Pin a route to a specific image tag",
25- Long: `Lists available image tags for a domain and deploys the selected version.
26-Tags are read from the Gordon registry.
27-
28-Examples:
29- gordon pin myapp.example.com --remote ...
30- gordon pin myapp.example.com --tag v1.0.0 --remote ...
31- gordon pin list myapp.example.com --remote ...`,
32- Args: cobra.ExactArgs(1),
33- RunE: func(cmd *cobra.Command, args []string) error {
34- return runPin(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], targetTag)
35- },
36- }
37-
38- cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39- cmd.AddCommand(newPinListCmd())
40-
41- return cmd
42-}
43-
44-func newPinListCmd() *cobra.Command {
45- var jsonOut bool
46-
47- cmd := &cobra.Command{
48- Use: "list <domain>",
49- Short: "List available tags",
50- Args: cobra.ExactArgs(1),
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return runPinList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
53- },
54- }
55-
56- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57-
58- return cmd
59-}
60-
61-func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
62- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
63- if err != nil {
64- return fmt.Errorf("failed to resolve control plane: %w", err)
65- }
66- defer handle.close()
67-
68- route, err := handle.plane.GetRoute(ctx, pinDomain)
69- if err != nil {
70- return fmt.Errorf("failed to get route: %w", err)
71- }
72-
73- _, imageName, currentTag := parseImageRef(route.Image)
74- if imageName == "" {
75- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
76- }
77-
78- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
79- if err != nil {
80- return fmt.Errorf("failed to fetch tags: %w", err)
81- }
82-
83- selectedTag, err := selectTag(targetTag, tags, currentTag, pinDomain, out)
84- if err != nil {
85- return fmt.Errorf("failed to select tag: %w", err)
86- }
87- if selectedTag == "" {
88- return nil
89- }
90-
91- if selectedTag == currentTag {
92- return cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
93- }
94-
95- return deploySelectedTag(ctx, handle.plane, route, out, ew, pinDomain, imageName, selectedTag)
96-}
97-
98-func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
99- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
100- if err != nil {
101- return fmt.Errorf("failed to resolve control plane: %w", err)
102- }
103- defer handle.close()
104-
105- route, err := handle.plane.GetRoute(ctx, pinDomain)
106- if err != nil {
107- return fmt.Errorf("failed to get route: %w", err)
108- }
109-
110- _, imageName, currentTag := parseImageRef(route.Image)
111- if imageName == "" {
112- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
113- }
114-
115- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
116- if err != nil {
117- return fmt.Errorf("failed to fetch tags: %w", err)
118- }
119-
120- return printPinTags(w, pinDomain, currentTag, tags, jsonOut)
121-}
122-
123-func printPinTags(w io.Writer, pinDomain, currentTag string, tags []string, jsonOut bool) error {
124- if jsonOut {
125- return writeJSON(w, map[string]any{
126- "domain": pinDomain,
127- "current_tag": currentTag,
128- "tags": tags,
129- })
130- }
131-
132- if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(pinDomain)); err != nil {
133- return err
134- }
135- for _, tag := range tags {
136- suffix := ""
137- if tag == currentTag {
138- suffix = " (current)"
139- }
140- if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil {
141- return err
142- }
143- }
144- return nil
145-}
146-
147-func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
148- tags, err := cp.ListTags(ctx, imageName)
149- if err != nil {
150- return nil, fmt.Errorf("failed to list tags: %w", err)
151- }
152- if len(tags) == 0 {
153- return nil, fmt.Errorf("no tags found for %s", imageName)
154- }
155-
156- return sortSemverTags(tags), nil
157-}
158-
159-// sortSemverTags normalizes, classifies, and sorts tags.
160-// Semver tags (with or without 'v' prefix) are sorted in descending order first,
161-// followed by non-semver tags in descending lexicographic order.
162-func sortSemverTags(tags []string) []string {
163- var semverTags, otherTags []string
164- for _, tag := range tags {
165- sv := tag
166- if !strings.HasPrefix(sv, "v") {
167- sv = "v" + sv
168- }
169- if semver.IsValid(sv) {
170- semverTags = append(semverTags, tag)
171- } else {
172- otherTags = append(otherTags, tag)
173- }
174- }
175-
176- // Sort semver tags in descending order (latest first)
177- sort.Slice(semverTags, func(i, j int) bool {
178- si, sj := semverTags[i], semverTags[j]
179- if !strings.HasPrefix(si, "v") {
180- si = "v" + si
181- }
182- if !strings.HasPrefix(sj, "v") {
183- sj = "v" + sj
184- }
185- return semver.Compare(si, sj) > 0
186- })
187-
188- // Sort non-semver tags in descending lexicographic order
189- sort.Slice(otherTags, func(i, j int) bool {
190- return otherTags[i] > otherTags[j]
191- })
192-
193- // Combine: semver tags first, then other tags
194- return append(semverTags, otherTags...)
195-}
196-
197-func selectTag(targetTag string, tags []string, currentTag, pinDomain string, out io.Writer) (string, error) {
198- if targetTag != "" {
199- if !validateTagExists(targetTag, tags) {
200- return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
201- }
202- return targetTag, nil
203- }
204-
205- selectedTag, err := components.RunSelector(
206- fmt.Sprintf("Select version for %s:", pinDomain),
207- tags,
208- currentTag,
209- )
210- if err != nil {
211- return "", err
212- }
213- if selectedTag == "" {
214- _ = cliWriteLine(out, "Cancelled.")
215- }
216- return selectedTag, nil
217-}
218-
219-func validateTagExists(targetTag string, tags []string) bool {
220- return slices.Contains(tags, targetTag)
221-}
222-
223-func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, out, ew io.Writer, pinDomain, imageName, selectedTag string) error {
224- registry, _, _ := parseImageRef(route.Image)
225- oldImage := route.Image
226- route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
227-
228- if err := cliWritef(out, "Pinning to %s...\n", styles.Theme.Bold.Render(selectedTag)); err != nil {
229- return err
230- }
231-
232- if err := cp.UpdateRoute(ctx, *route); err != nil {
233- return fmt.Errorf("failed to update route: %w", err)
234- }
235-
236- result, err := cp.Deploy(ctx, pinDomain)
237- if err != nil {
238- formattedErr := formatDeployFailure(err)
239- // Attempt to revert route to previous image
240- route.Image = oldImage
241- if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242- _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243- return fmt.Errorf("failed to deploy; revert failed: %w; deploy error: %v", revertErr, formattedErr)
244- }
245- return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr)
246- }
247- containerID := shortContainerID(result.ContainerID)
248- return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
249-}
D internal/adapters/in/cli/pin_test.go +0 -104
@@ -1,104 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "testing"
8-
9- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
10- "github.com/bnema/gordon/internal/domain"
11- "github.com/stretchr/testify/assert"
12-)
13-
14-type failingWriter struct{}
15-
16-func (failingWriter) Write(_ []byte) (int, error) {
17- return 0, errors.New("write failed")
18-}
19-
20-func TestSortSemverTags(t *testing.T) {
21- tests := []struct {
22- name string
23- tags []string
24- wantTags []string
25- }{
26- {
27- name: "v-prefixed tags sorted descending",
28- tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"},
29- wantTags: []string{"v2.0.0", "v1.5.0", "v1.0.0"},
30- },
31- {
32- name: "non-v-prefixed tags sorted as semver",
33- tags: []string{"1.0.0", "2.0.0", "1.5.0"},
34- wantTags: []string{"2.0.0", "1.5.0", "1.0.0"},
35- },
36- {
37- name: "mixed v and non-v prefixed",
38- tags: []string{"v1.0.0", "2.0.0", "1.5.0"},
39- wantTags: []string{"2.0.0", "1.5.0", "v1.0.0"}, // all recognized as semver, sorted desc
40- },
41- {
42- name: "pre-release tags",
43- tags: []string{"v1.0.0-rc1", "v1.0.0", "1.0.0-beta"},
44- wantTags: []string{"v1.0.0", "v1.0.0-rc1", "1.0.0-beta"}, // release > pre-release
45- },
46- {
47- name: "non-semver tags appended after",
48- tags: []string{"latest", "v1.0.0", "dev"},
49- wantTags: []string{"v1.0.0", "latest", "dev"},
50- },
51- }
52-
53- for _, tt := range tests {
54- t.Run(tt.name, func(t *testing.T) {
55- gotTags := sortSemverTags(tt.tags)
56- assert.Equal(t, tt.wantTags, gotTags)
57- })
58- }
59-}
60-
61-func TestValidateTagExists(t *testing.T) {
62- assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
63- assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
64-}
65-
66-func TestPrintPinTags_WritesToProvidedWriter(t *testing.T) {
67- var buf bytes.Buffer
68-
69- err := printPinTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
70- assert.NoError(t, err)
71-
72- output := buf.String()
73- assert.Contains(t, output, "Available tags for")
74- assert.Contains(t, output, "myapp.example.com")
75- assert.Contains(t, output, "- v1.2.0 (current)")
76- assert.Contains(t, output, "- v1.1.0")
77-}
78-
79-func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
80- err := printPinTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
81- assert.Error(t, err)
82- assert.Contains(t, err.Error(), "write failed")
83-}
84-
85-func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
86- cp := climocks.NewMockControlPlane(t)
87- route := &domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}
88- deployErr := &domain.DeployFailureError{
89- Summary: "failed to deploy",
90- Cause: "health check failed",
91- Hint: "check DATABASE_URL",
92- }
93-
94- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2"}).Return(nil).Once()
95- cp.EXPECT().Deploy(context.Background(), "app.example.com").Return(nil, deployErr).Once()
96- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}).Return(nil).Once()
97-
98- err := deploySelectedTag(context.Background(), cp, route, &bytes.Buffer{}, &bytes.Buffer{}, "app.example.com", "myapp", "v2")
99- assert.Error(t, err)
100- assert.Contains(t, err.Error(), "failed to deploy")
101- assert.Contains(t, err.Error(), "Cause: health check failed")
102- assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103- assert.Contains(t, err.Error(), "route reverted to previous image")
104-}
M internal/adapters/in/cli/presentation.go +0 -8
@@ -63,11 +63,3 @@ func cliRenderWarning(msg string) string {
6363 func cliRenderInfo(msg string) string {
6464 return styles.RenderInfo(msg)
6565 }
66-
67-func shortContainerID(id string) string {
68- if len(id) > 12 {
69- return id[:12]
70- }
71-
72- return id
73-}
D internal/adapters/in/cli/preview.go +0 -469
@@ -1,469 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "os"
8- "os/exec"
9- "strings"
10- "text/tabwriter"
11- "time"
12-
13- tea "github.com/charmbracelet/bubbletea"
14- "github.com/spf13/cobra"
15-
16- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18- "github.com/bnema/gordon/internal/domain"
19- "github.com/bnema/gordon/internal/usecase/auto/preview"
20-)
21-
22-func newPreviewCmd() *cobra.Command {
23- cmd := &cobra.Command{
24- Use: "preview",
25- Short: "Create or manage preview environments",
26- Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
27- }
28-
29- createCmd := newPreviewCreateCmd()
30-
31- cmd.AddCommand(
32- createCmd,
33- newPreviewListCmd(),
34- newPreviewDeleteCmd(),
35- newPreviewExtendCmd(),
36- )
37-
38- return cmd
39-}
40-
41-func newPreviewCreateCmd() *cobra.Command {
42- var (
43- ttl string
44- noBuild bool
45- noData bool
46- platform string
47- )
48-
49- cmd := &cobra.Command{
50- Use: "create [name]",
51- Short: "Create a preview environment",
52- Args: cobra.MaximumNArgs(1),
53- RunE: func(cmd *cobra.Command, args []string) error {
54- ctx := cmd.Context()
55- out := cmd.OutOrStdout()
56-
57- // Resolve preview name from arg or git branch.
58- name, err := resolvePreviewName(ctx, args)
59- if err != nil {
60- return err
61- }
62-
63- previewTag := "preview-" + name
64-
65- if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil {
66- return err
67- }
68-
69- // Resolve control plane (remote or local) — same as push.
70- handle, err := resolveControlPlane(configPath)
71- if err != nil {
72- return err
73- }
74- defer handle.close()
75-
76- previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag)
77- if err != nil {
78- return err
79- }
80-
81- if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil {
82- return err
83- }
84-
85- return waitForPreview(ctx, out, name, ttl, noData)
86- },
87- }
88-
89- cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
90- cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
91- cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
92- cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
93- return cmd
94-}
95-
96-func newPreviewListCmd() *cobra.Command {
97- var jsonOutput bool
98- cmd := &cobra.Command{
99- Use: "list",
100- Short: "List active preview environments",
101- RunE: func(cmd *cobra.Command, args []string) error {
102- ctx := cmd.Context()
103- out := cmd.OutOrStdout()
104-
105- client, isRemote, err := GetRemoteClient()
106- if err != nil {
107- return err
108- }
109- if !isRemote {
110- return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
111- }
112-
113- previews, err := client.ListPreviews(ctx)
114- if err != nil {
115- return fmt.Errorf("failed to list previews: %w", err)
116- }
117-
118- if jsonOutput {
119- return writeJSON(out, previews)
120- }
121-
122- if len(previews) == 0 {
123- return cliWriteLine(out, cliRenderEmptyState("No active previews"))
124- }
125-
126- return printPreviewTable(out, previews)
127- },
128- }
129- cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
130- return cmd
131-}
132-
133-func newPreviewDeleteCmd() *cobra.Command {
134- return &cobra.Command{
135- Use: "delete <name>",
136- Short: "Delete a preview environment",
137- Args: cobra.ExactArgs(1),
138- RunE: func(cmd *cobra.Command, args []string) error {
139- ctx := cmd.Context()
140- out := cmd.OutOrStdout()
141- name := args[0]
142-
143- client, isRemote, err := GetRemoteClient()
144- if err != nil {
145- return err
146- }
147- if !isRemote {
148- return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
149- }
150-
151- if err := client.DeletePreview(ctx, name); err != nil {
152- return fmt.Errorf("failed to delete preview %q: %w", name, err)
153- }
154-
155- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
156- },
157- }
158-}
159-
160-func newPreviewExtendCmd() *cobra.Command {
161- var ttl string
162- cmd := &cobra.Command{
163- Use: "extend <name>",
164- Short: "Extend a preview environment's TTL",
165- Args: cobra.ExactArgs(1),
166- RunE: func(cmd *cobra.Command, args []string) error {
167- ctx := cmd.Context()
168- out := cmd.OutOrStdout()
169- name := args[0]
170-
171- client, isRemote, err := GetRemoteClient()
172- if err != nil {
173- return err
174- }
175- if !isRemote {
176- return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
177- }
178-
179- if err := client.ExtendPreview(ctx, name, ttl); err != nil {
180- return fmt.Errorf("failed to extend preview %q: %w", name, err)
181- }
182-
183- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
184- },
185- }
186- cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration")
187- return cmd
188-}
189-
190-func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error {
191- w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
192- if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
193- return err
194- }
195- now := time.Now()
196- for _, p := range previews {
197- remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
198- expiresStr := remaining.String()
199- if p.IsExpired(now) {
200- expiresStr = "expired"
201- }
202- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
203- p.Name,
204- p.Domain,
205- truncateImage(p.Image, 40),
206- p.CreatedAt.Format(time.DateTime),
207- expiresStr,
208- ); err != nil {
209- return err
210- }
211- }
212- return w.Flush()
213-}
214-
215-func resolvePreviewName(ctx context.Context, args []string) (string, error) {
216- if len(args) > 0 {
217- name := preview.SanitizeBranchName(args[0])
218- if name == "" {
219- return "", fmt.Errorf("invalid preview name after sanitization: %q", args[0])
220- }
221- return name, nil
222- }
223- branch, err := detectGitBranch(ctx)
224- if err != nil {
225- return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err)
226- }
227- name := preview.SanitizeBranchName(branch)
228- if name == "" {
229- return "", fmt.Errorf("git branch %q produced empty preview name after sanitization", branch)
230- }
231- return name, nil
232-}
233-
234-func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) {
235- dockerfile := "Dockerfile"
236- imageName, err := detectImageName(dockerfile)
237- if err != nil {
238- return "", err
239- }
240- if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil {
241- return "", err
242- }
243-
244- // Resolve registry from existing routes without showing the interactive
245- // selector — preview only needs the registry URL, not a target domain.
246- registry, err := resolveRegistryForImage(ctx, cp, imageName)
247- if err != nil {
248- return "", err
249- }
250-
251- previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
252- if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil {
253- return "", err
254- }
255- return previewRef, nil
256-}
257-
258-// resolveRegistryForImage finds the registry URL from any existing non-preview
259-// route for the given image, without prompting.
260-func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
261- routes, err := cp.FindRoutesByImage(ctx, imageName)
262- if err != nil {
263- return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
264- }
265-
266- for _, r := range routes {
267- if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
268- continue
269- }
270- reg, _, _ := parseImageRef(r.Image)
271- if reg != "" {
272- return reg, nil
273- }
274- }
275-
276- return "", fmt.Errorf("no route configured for image %q", imageName)
277-}
278-
279-func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
280- imageOps, err := newImageOpsFn()
281- if err != nil {
282- return err
283- }
284-
285- if !noBuild {
286- dockerfile := "Dockerfile"
287- if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
288- return fmt.Errorf("dockerfile not found: %s", dockerfile)
289- }
290- buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef)
291- if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
292- return err
293- }
294- if err := imageOps.Build(ctx, buildArgs); err != nil {
295- return err
296- }
297- }
298-
299- if err := cliWriteLine(out, "Pushing..."); err != nil {
300- return err
301- }
302- if err := imageOps.Push(ctx, previewRef); err != nil {
303- return fmt.Errorf("failed to push %s: %w", previewRef, err)
304- }
305- return nil
306-}
307-
308-func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
309- if ttl != "" {
310- if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
311- return err
312- }
313- }
314- if noData {
315- if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil {
316- return err
317- }
318- }
319- return nil
320-}
321-
322-func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
323- if err := printPreviewFlags(out, ttl, noData); err != nil {
324- return err
325- }
326-
327- client, isRemote, err := GetRemoteClient()
328- if err != nil {
329- return err
330- }
331- if !isRemote {
332- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
333- }
334-
335- result, err := pollPreviewWithSpinner(ctx, out, client, name)
336- if err != nil {
337- return err
338- }
339-
340- switch result.Status {
341- case domain.PreviewStatusRunning:
342- scheme := "http"
343- if result.HTTPS {
344- scheme = "https"
345- }
346- previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
347- if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
348- return err
349- }
350- return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
351- case domain.PreviewStatusFailed:
352- return fmt.Errorf("preview deployment failed")
353- default:
354- return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
355- }
356-}
357-
358-type previewPollResult struct {
359- preview *domain.PreviewRoute
360- err error
361-}
362-
363-type previewPollDoneMsg previewPollResult
364-
365-type previewSpinnerModel struct {
366- spinner components.SpinnerModel
367- done <-chan previewPollResult
368- outcome previewPollResult
369- finished bool
370-}
371-
372-func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
373- return previewSpinnerModel{
374- spinner: components.NewSpinner(
375- components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
376- components.WithSpinnerType(components.SpinnerMiniDot),
377- ),
378- done: done,
379- }
380-}
381-
382-func (m previewSpinnerModel) Init() tea.Cmd {
383- return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
384-}
385-
386-func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387- switch msg := msg.(type) {
388- case previewPollDoneMsg:
389- m.outcome = previewPollResult(msg)
390- m.finished = true
391- return m, tea.Quit
392- default:
393- updated, cmd := m.spinner.Update(msg)
394- if sm, ok := updated.(components.SpinnerModel); ok {
395- m.spinner = sm
396- }
397- return m, cmd
398- }
399-}
400-
401-func (m previewSpinnerModel) View() string {
402- return m.spinner.View()
403-}
404-
405-func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
406- return func() tea.Msg {
407- return previewPollDoneMsg(<-done)
408- }
409-}
410-
411-func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
412- done := make(chan previewPollResult, 1)
413- go func() {
414- result, err := pollPreviewStatus(ctx, client, name)
415- done <- previewPollResult{preview: result, err: err}
416- }()
417-
418- if !isInteractiveTerminal() {
419- if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
420- return nil, err
421- }
422- r := <-done
423- return r.preview, r.err
424- }
425-
426- model := newPreviewSpinnerModel(name, done)
427- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
428- fmt.Print("\r\033[K")
429- if err != nil {
430- return nil, err
431- }
432-
433- m, ok := final.(previewSpinnerModel)
434- if !ok || !m.finished {
435- return nil, fmt.Errorf("preview spinner exited unexpectedly")
436- }
437- return m.outcome.preview, m.outcome.err
438-}
439-
440-func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
441- ticker := time.NewTicker(2 * time.Second)
442- defer ticker.Stop()
443- timeout := time.After(3 * time.Minute)
444-
445- for {
446- select {
447- case <-ctx.Done():
448- return nil, ctx.Err()
449- case <-timeout:
450- return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
451- case <-ticker.C:
452- p, err := client.GetPreview(ctx, name)
453- if err != nil {
454- continue
455- }
456- if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
457- return p, nil
458- }
459- }
460- }
461-}
462-
463-func detectGitBranch(ctx context.Context) (string, error) {
464- out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
465- if err != nil {
466- return "", fmt.Errorf("detect git branch: %w", err)
467- }
468- return strings.TrimSpace(string(out)), nil
469-}
A internal/adapters/in/cli/prune.go +76 -0
@@ -0,0 +1,76 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/dto"
8+ "github.com/bnema/gordon/internal/domain"
9+ "github.com/bnema/gordon/pkg/bytesize"
10+)
11+
12+// cliRenderPruneSummary writes the shared prune verdict summary: counts
13+// per verdict, what was removed, and the reasons protected or unknown
14+// candidates were skipped. Dry-run and execution use the same renderer.
15+func cliRenderPruneSummary(out io.Writer, summary dto.PruneSummary) error {
16+ action := "Deleted"
17+ if !summary.Applied {
18+ action = "Would delete"
19+ }
20+ // The wire summary carries the authoritative counts, so the human
21+ // output can never disagree with the JSON body.
22+ actionCount := len(summary.Deleted)
23+ if !summary.Applied {
24+ actionCount = summary.Eligible
25+ }
26+ if err := cliWritef(out, "%s: %d (eligible=%d protected=%d unknown=%d)\n",
27+ action, actionCount, summary.Eligible, summary.Protected, summary.Unknown); err != nil {
28+ return err
29+ }
30+ if summary.ReclaimedKnown {
31+ if err := cliWritef(out, "Space reclaimed: %s\n", bytesize.Format(summary.ReclaimedBytes)); err != nil {
32+ return err
33+ }
34+ }
35+ for _, failure := range summary.Failures {
36+ line := fmt.Sprintf("failed to delete %s %s: %s", failure.Kind, failure.Ref, failure.Error)
37+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
38+ return err
39+ }
40+ }
41+ for _, gap := range summary.Gaps {
42+ line := fmt.Sprintf("inventory incomplete (%s: %s); dependent candidates were left untouched", gap.Source, gap.Reason)
43+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
44+ return err
45+ }
46+ }
47+ return cliRenderSkippedCandidates(out, summary.Candidates)
48+}
49+
50+// cliRenderSkippedCandidates lists the not-deleted candidates with their
51+// first reason, so an operator can see why each resource survived.
52+func cliRenderSkippedCandidates(out io.Writer, candidates []dto.PruneCandidate) error {
53+ skipped := make([]dto.PruneCandidate, 0, len(candidates))
54+ for _, candidate := range candidates {
55+ if candidate.Verdict == string(domain.PruneVerdictEligible) {
56+ continue
57+ }
58+ skipped = append(skipped, candidate)
59+ }
60+ if len(skipped) == 0 {
61+ return nil
62+ }
63+ if err := cliWriteLine(out, cliRenderMuted("Skipped:")); err != nil {
64+ return err
65+ }
66+ for _, candidate := range skipped {
67+ reason := ""
68+ if len(candidate.Reasons) > 0 {
69+ reason = candidate.Reasons[0]
70+ }
71+ if err := cliWritef(out, " %s %s (%s) %s\n", candidate.Kind, candidate.Ref, candidate.Verdict, reason); err != nil {
72+ return err
73+ }
74+ }
75+ return nil
76+}
M internal/adapters/in/cli/push.go +70 -382
@@ -6,7 +6,6 @@ import (
66 "errors"
77 "fmt"
88 "io"
9- "net/http"
109 "os"
1110 "os/exec"
1211 "path/filepath"
@@ -14,11 +13,9 @@ import (
1413 "strings"
1514 "time"
1615
17- tea "github.com/charmbracelet/bubbletea"
1816 "github.com/spf13/cobra"
1917
2018 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
2219 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
2320 "github.com/bnema/gordon/internal/domain"
2421 "github.com/bnema/gordon/pkg/validation"
@@ -38,6 +35,7 @@ type buildConfig struct {
3835 type imagePush struct {
3936 Registry string
4037 ImageName string
38+ SourceRef string
4139 Version string
4240 VersionRef string
4341 LatestRef string
@@ -45,48 +43,33 @@ type imagePush struct {
4543
4644 // pushRequest holds all inputs for the push command.
4745 type pushRequest struct {
48- ImageArg string
49- Domain string
50- Tag string
51- Build buildConfig
52- NoDeploy bool
53- NoConfirm bool
46+ ImageArg string
47+ Tag string
48+ Build buildConfig
5449 }
5550
5651 func newPushCmd() *cobra.Command {
5752 var (
58- noDeploy bool
59- noConfirm bool
6053 build bool
6154 platform string
6255 tag string
6356 dockerfile string
6457 buildArgs []string
65- domainFlag string
6658 )
6759
6860 cmd := &cobra.Command{
6961 Use: "push [image]",
70- Short: "Tag, push, and optionally deploy an image",
62+ Short: "Tag and push an image to the Gordon registry",
7163 Long: `Tags a local image for the Gordon registry and pushes it.
72-Uses git tags for versioning. Optionally triggers deployment after push.
73-
74-Image-first syntax is primary:
75- - Positional args resolve routes by image name first
76- - Tagged positional refs still resolve routes by image name
77- - The pushed version comes from --tag, CI tag refs, or git describe
78- - Dotted positional refs fall back to legacy domain lookup if no image route exists
79- - --domain is a deploy target override for legacy workflows
80- - No-arg mode still auto-detects from Dockerfile labels or current directory name
81-
82-With --build, builds the image first using docker buildx.
64+Uses git tags for versioning. Push transfers OCI content only: it never
65+deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
66+the manifest that references the pushed tag.
8367
8468 Examples:
85- gordon push myapp --build --remote ...
86- gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...
87- 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 ...`,
69+ gordon images push myapp --build --remote ...
70+ gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
71+ gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
72+ gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
9073 Args: cobra.MaximumNArgs(1),
9174 RunE: func(cmd *cobra.Command, args []string) error {
9275 var imageArg string
@@ -94,24 +77,18 @@ Examples:
9477 imageArg = args[0]
9578 }
9679 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
97- ImageArg: imageArg,
98- Domain: domainFlag,
99- Tag: tag,
100- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
101- NoDeploy: noDeploy,
102- NoConfirm: noConfirm,
80+ ImageArg: imageArg,
81+ Tag: tag,
82+ Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
10383 })
10484 },
10585 }
10686
107- cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
108- cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
10987 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
11088 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
11189 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
11290 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
11391 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
114- cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
11592
11693 return cmd
11794 }
@@ -141,7 +118,8 @@ const (
141118
142119 type classifiedPushArg struct {
143120 kind pushArgKind
144- lookupImage string
121+ sourceRef string
122+ repository string
145123 legacyDomain string
146124 }
147125
@@ -150,11 +128,11 @@ func classifyPushArgument(arg string) classifiedPushArg {
150128 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
151129 }
152130
153- classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}
154- if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {
155- classified.lookupImage = parsedImage
131+ _, repository, _ := parseImageRef(arg)
132+ if repository == "" {
133+ repository, _ = validation.ParseImageReference(arg)
156134 }
157- return classified
135+ return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
158136 }
159137
160138 func looksLikeLegacyDomain(arg string) bool {
@@ -168,51 +146,29 @@ func looksLikeLegacyDomain(arg string) bool {
168146 return strings.Contains(host, ".")
169147 }
170148
171-// resolveRoute determines the registry, image name, and domain from the input mode.
172-func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
173- if domainFlag != "" {
174- return resolveFromDomain(ctx, cp, domainFlag)
175- }
176-
149+// resolveImageTarget determines the registry and image name from the input.
150+// Push transfers OCI content only and never triggers deploys or route
151+// lookups. The deploy target comes from the app manifest applied
152+// separately via `gordon apps apply`.
153+func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
177154 if imageArg == "" {
178155 imageArg, err = detectImageName(dockerfile)
179156 if err != nil {
180157 return "", "", "", err
181158 }
182- fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
183- }
184-
185- if looksLikeLegacyDomain(imageArg) {
186- lookupImage := imageArg
187- domainLookupArg := imageArg
188- if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
189- domainLookupArg = parsedImage
190- if !strings.HasPrefix(ref, "sha256:") {
191- lookupImage = parsedImage
192- }
193- }
194-
195- registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
196- if err == nil {
197- return registry, imageName, pushDomain, nil
198- }
199- if !isImageBootstrapError(err) {
159+ if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
200160 return "", "", "", err
201161 }
202-
203- registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
204- if err == nil {
205- return registry, imageName, pushDomain, nil
206- }
207- if errors.Is(err, domain.ErrRouteNotFound) {
208- return "", "", "", noRouteForImageError(domainLookupArg)
209- }
210- return "", "", "", err
211162 }
212-
163+ if looksLikeLegacyDomain(imageArg) {
164+ return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165+ }
213166 classified := classifyPushArgument(imageArg)
214-
215- return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)
167+ registry, imageName, _ = parseImageRef(classified.sourceRef)
168+ if registry == "" || imageName == "" {
169+ return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
170+ }
171+ return registry, imageName, classified.sourceRef, nil
216172 }
217173
218174 // resolveVersion determines and validates the version tag.
@@ -233,7 +189,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
233189 }
234190 defer handle.close()
235191
236- registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
192+ registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
237193 if err != nil {
238194 return err
239195 }
@@ -246,7 +202,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
246202 return err
247203 }
248204
249- img := imagePush{Registry: registry, ImageName: imageName, Version: version}
205+ img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
250206 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
251207
252208 imageOps, err := newPushImageOps(inferredRemote)
@@ -262,22 +218,14 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
262218 return err
263219 }
264220 }
265- if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266- return err
267- }
268-
269- skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270221 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271- if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
222+ if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
272223 return err
273224 }
274225
275226 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276227 return err
277228 }
278- if !skipExplicitDeploy {
279- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280- }
281229 return nil
282230 }
283231
@@ -286,7 +234,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
286234 if err != nil {
287235 return "", nil, nil, err
288236 }
289- inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
237+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
290238 if err != nil {
291239 return "", nil, nil, err
292240 }
@@ -297,7 +245,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
297245 }
298246 return dockerfile, inferredRemote, handle, nil
299247 }
300- handle, err = resolveControlPlane(configPath)
248+ handle, err = resolveControlPlane(cliConfigPath)
301249 if err != nil {
302250 return "", nil, nil, err
303251 }
@@ -320,179 +268,15 @@ func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error
320268 return newImageOpsFn()
321269 }
322270
323-func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
271+func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
324272 if build.Enabled {
325- return buildAndPush(ctx, imageOps, build, img)
326- }
327- return tagAndPush(ctx, imageOps, img)
328-}
329-
330-// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
331-// should be skipped (either because --no-deploy was set, or the token lacks scope).
332-func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
333- // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
334- // Without this, the server's event listener would still auto-deploy the pushed image.
335- if err := plane.DeployIntent(ctx, imageName); err != nil {
336- if isInsufficientScope(err) {
337- fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
338- return true
339- }
340- // Non-fatal: worst case we get a redundant deploy via event
341- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
342- }
343- return noDeploy
344-}
345-
346-// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
347-// meaning the token lacks the required admin scope.
348-func isInsufficientScope(err error) bool {
349- var httpErr *remote.HTTPError
350- return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
351-}
352-
353-// resolveFromDomain resolves image info from a domain name (legacy mode).
354-func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
355- route, err := cp.GetRoute(ctx, pushDomain)
356- if err != nil {
357- return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
358- }
359-
360- registry, imageName, _ = parseImageRef(route.Image)
361- if registry == "" || imageName == "" {
362- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
363- }
364-
365- return registry, imageName, pushDomain, nil
366-}
367-
368-// resolveFromImage resolves domain(s) from an image name using the backend.
369-func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
370- // First, query the backend to find routes for this image
371- routes, err := cp.FindRoutesByImage(ctx, imageArg)
372- if err != nil {
373- return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
374- }
375-
376- routes = filterPreviewRoutes(routes)
377-
378- if len(routes) == 0 {
379- return "", "", "", noRouteForImageError(imageArg)
380- }
381-
382- // Pick the target domain
383- var targetRoute domain.Route
384- if len(routes) == 1 {
385- targetRoute = routes[0]
386- } else {
387- // Multiple domains: check Dockerfile labels first, then prompt
388- targetRoute, err = selectDomain(routes, dockerfile)
389- if err != nil {
390- return "", "", "", err
391- }
392- }
393-
394- registry, imageName, _ = parseImageRef(targetRoute.Image)
395- if registry == "" || imageName == "" {
396- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
397- }
398-
399- return registry, imageName, targetRoute.Domain, nil
400-}
401-
402-func isImageBootstrapError(err error) bool {
403- return errors.Is(err, domain.ErrNoRouteForImage)
404-}
405-
406-func noRouteForImageError(imageArg string) error {
407- // bootstrap command is registered in bootstrap.go (see issue #98)
408- return noRouteForImageBootstrapError{imageArg: imageArg}
409-}
410-
411-type noRouteForImageBootstrapError struct {
412- imageArg string
413-}
414-
415-func (e noRouteForImageBootstrapError) Error() string {
416- return fmt.Sprintf(
417- "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
418- e.imageArg,
419- e.imageArg,
420- e.imageArg,
421- e.imageArg,
422- )
423-}
424-
425-func (e noRouteForImageBootstrapError) Unwrap() error {
426- return domain.ErrNoRouteForImage
427-}
428-
429-// selectDomain picks the target domain from multiple routes.
430-// Checks Dockerfile labels first, then falls back to interactive selection.
431-func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
432- // Try to resolve from Dockerfile labels
433- labels := parseDockerfileLabels(dockerfile)
434- labelDomain := labels[domain.LabelDomain]
435- labelDomains := labels[domain.LabelDomains]
436-
437- // Collect domains from labels
438- var labelDomainList []string
439- if labelDomain != "" {
440- labelDomainList = append(labelDomainList, labelDomain)
441- }
442- if labelDomains != "" {
443- for d := range strings.SplitSeq(labelDomains, ",") {
444- d = strings.TrimSpace(d)
445- if d != "" {
446- labelDomainList = append(labelDomainList, d)
447- }
448- }
449- }
450-
451- // Try to find a matching route from labels
452- if len(labelDomainList) > 0 {
453- routeMap := make(map[string]domain.Route, len(routes))
454- for _, r := range routes {
455- routeMap[r.Domain] = r
456- }
457- for _, ld := range labelDomainList {
458- if r, ok := routeMap[ld]; ok {
459- return r, nil
460- }
461- }
462- }
463-
464- // Fall back to interactive selection
465- items := make([]string, 0, len(routes))
466- for _, r := range routes {
467- items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
468- }
469-
470- selected, err := components.RunSelector(
471- "Multiple domains found for this image. Select target:",
472- items,
473- "",
474- )
475- if err != nil {
476- return domain.Route{}, fmt.Errorf("selection error: %w", err)
273+ return buildAndPush(ctx, out, imageOps, build, img)
477274 }
478- if selected == "" {
479- return domain.Route{}, fmt.Errorf("no domain selected")
480- }
481-
482- // Extract domain from the selected display string
483- for i, item := range items {
484- if item == selected {
485- return routes[i], nil
486- }
487- }
488-
489- return domain.Route{}, fmt.Errorf("selected domain not found")
275+ return tagAndPush(ctx, out, imageOps, img)
490276 }
491277
492-// detectImageName auto-detects the image name from context.
493-// Resolution order:
494-// 1. Dockerfile label gordon.domain (if Dockerfile exists)
495-// 2. Current directory name
278+// detectImageName resolves the image name from Dockerfile labels or the
279+// current directory name.
496280 func detectImageName(dockerfile string) (string, error) {
497281 // Try Dockerfile labels
498282 labels := parseDockerfileLabels(dockerfile)
@@ -513,7 +297,7 @@ func detectImageName(dockerfile string) (string, error) {
513297
514298 dirName := filepath.Base(cwd)
515299 if dirName == "." || dirName == "/" {
516- return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
300+ return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
517301 }
518302
519303 return dirName, nil
@@ -655,7 +439,7 @@ func parseTagRef(ref string) string {
655439 return tag
656440 }
657441
658-func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
442+func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
659443 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
660444 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
661445 }
@@ -663,12 +447,16 @@ func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img
663447 // Build and load into local daemon (NOT --push).
664448 // The native registry push client handles chunked uploads to stay
665449 // within Cloudflare's 100MB per-request limit.
666- fmt.Println("\nBuilding image...")
450+ if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
451+ return err
452+ }
667453 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
668454 return err
669455 }
670456
671- fmt.Println("Pushing...")
457+ if err := cliWriteLine(out, "Pushing..."); err != nil {
458+ return err
459+ }
672460 if err := ops.Push(ctx, img.LatestRef); err != nil {
673461 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
674462 }
@@ -731,10 +519,15 @@ func buildImageArgs(ctx context.Context, version, platform, dockerfile string, b
731519 return args
732520 }
733521
734-func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
735- localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)
522+func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
523+ localImage := img.SourceRef
524+ if localImage == "" {
525+ return errors.New("push source image reference cannot be empty")
526+ }
736527
737- fmt.Println("\nChecking local image...")
528+ if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
529+ return err
530+ }
738531 exists, err := ops.Exists(ctx, localImage)
739532 if err != nil {
740533 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
@@ -743,7 +536,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
743536 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
744537 }
745538
746- fmt.Println("Tagging...")
539+ if err := cliWriteLine(out, "Tagging..."); err != nil {
540+ return err
541+ }
747542 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
748543 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
749544 }
@@ -753,7 +548,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
753548 }
754549 }
755550
756- fmt.Println("Pushing...")
551+ if err := cliWriteLine(out, "Pushing..."); err != nil {
552+ return err
553+ }
757554 if err := ops.Push(ctx, img.VersionRef); err != nil {
758555 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
759556 }
@@ -765,118 +562,6 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
765562 return nil
766563 }
767564
768-func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
769- if !noConfirm {
770- confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
771- if err != nil {
772- return err
773- }
774- if !confirmed {
775- return nil
776- }
777- }
778-
779- var (
780- result *remote.DeployResult
781- err error
782- )
783- if remoteCP, ok := cp.(*remoteControlPlane); ok {
784- result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
785- } else {
786- result, err = cp.Deploy(ctx, pushDomain)
787- }
788- if err != nil {
789- return formatDeployFailure(err)
790- }
791- containerID := shortContainerID(result.ContainerID)
792- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
793- return nil
794-}
795-
796-func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
797- if !isInteractiveTerminal() {
798- fmt.Printf("Deploying %s...\n", pushDomain)
799- return client.Deploy(ctx, pushDomain)
800- }
801-
802- done := make(chan deployOutcome, 1)
803- go func() {
804- result, err := client.Deploy(ctx, pushDomain)
805- done <- deployOutcome{result: result, err: err}
806- }()
807-
808- model := newDeploySpinnerModel(pushDomain, done)
809- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
810- fmt.Print("\r\033[K")
811- if err != nil {
812- return nil, err
813- }
814-
815- deployModel, ok := final.(deploySpinnerModel)
816- if !ok {
817- return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
818- }
819- if !deployModel.finished {
820- return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
821- }
822-
823- return deployModel.outcome.result, deployModel.outcome.err
824-}
825-
826-type deployOutcome struct {
827- result *remote.DeployResult
828- err error
829-}
830-
831-type deployDoneMsg deployOutcome
832-
833-type deploySpinnerModel struct {
834- spinner components.SpinnerModel
835- done <-chan deployOutcome
836- outcome deployOutcome
837- finished bool
838-}
839-
840-func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
841- return deploySpinnerModel{
842- spinner: components.NewSpinner(
843- components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
844- components.WithSpinnerType(components.SpinnerMiniDot),
845- ),
846- done: done,
847- }
848-}
849-
850-func (m deploySpinnerModel) Init() tea.Cmd {
851- return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
852-}
853-
854-func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
855- switch msg := msg.(type) {
856- case deployDoneMsg:
857- m.outcome = deployOutcome(msg)
858- m.finished = true
859- return m, tea.Quit
860- default:
861- updated, cmd := m.spinner.Update(msg)
862- spinnerModel, ok := updated.(components.SpinnerModel)
863- if ok {
864- m.spinner = spinnerModel
865- }
866- return m, cmd
867- }
868-}
869-
870-func (m deploySpinnerModel) View() string {
871- return m.spinner.View()
872-}
873-
874-func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
875- return func() tea.Msg {
876- return deployDoneMsg(<-done)
877- }
878-}
879-
880565 func isInteractiveTerminal() bool {
881566 term := os.Getenv("TERM")
882567 if term == "" || term == "dumb" {
@@ -898,6 +583,9 @@ func parseImageRef(image string) (registry, name, tag string) {
898583 }
899584 registry = parts[0]
900585 nameTag := parts[1]
586+ if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
587+ return registry, nameTag[:idx], nameTag[idx+1:]
588+ }
901589 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
902590 name = nameTag[:idx]
903591 tag = nameTag[idx+1:]
A internal/adapters/in/cli/push_behavior_test.go +61 -0
@@ -0,0 +1,61 @@
1+package cli
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type recordingPushOps struct {
12+ existsRef string
13+ tags [][2]string
14+ pushes []string
15+ exists bool
16+}
17+
18+func (o *recordingPushOps) Exists(_ context.Context, ref string) (bool, error) {
19+ o.existsRef = ref
20+ return o.exists, nil
21+}
22+
23+func (o *recordingPushOps) Tag(_ context.Context, source, target string) error {
24+ o.tags = append(o.tags, [2]string{source, target})
25+ return nil
26+}
27+
28+func (o *recordingPushOps) Push(_ context.Context, ref string) error {
29+ o.pushes = append(o.pushes, ref)
30+ return nil
31+}
32+
33+func (*recordingPushOps) Build(context.Context, []string) error { return nil }
34+
35+func TestTagAndPushPreservesExactSourceReference(t *testing.T) {
36+ ops := &recordingPushOps{exists: true}
37+ img := imagePush{
38+ SourceRef: "registry.example.com/team/app:v3",
39+ Version: "v4", VersionRef: "registry.example.com/team/app:v4",
40+ LatestRef: "registry.example.com/team/app:latest",
41+ }
42+
43+ require.NoError(t, tagAndPush(context.Background(), io.Discard, ops, img))
44+ require.Equal(t, img.SourceRef, ops.existsRef)
45+ require.Equal(t, [][2]string{
46+ {img.SourceRef, img.VersionRef},
47+ {img.SourceRef, img.LatestRef},
48+ }, ops.tags)
49+ require.Equal(t, []string{img.VersionRef, img.LatestRef}, ops.pushes)
50+}
51+
52+func TestTagAndPushRejectsMissingSourceReference(t *testing.T) {
53+ ops := &recordingPushOps{exists: true}
54+ err := tagAndPush(context.Background(), io.Discard, ops, imagePush{
55+ Registry: "registry.example.com", ImageName: "team/app", Version: "v4",
56+ })
57+ require.EqualError(t, err, "push source image reference cannot be empty")
58+ require.Empty(t, ops.existsRef)
59+ require.Empty(t, ops.tags)
60+ require.Empty(t, ops.pushes)
61+}
M internal/adapters/in/cli/push_image_ops.go +9 -3
@@ -35,6 +35,7 @@ type pushImageOps interface {
3535 type dockerImageOps struct {
3636 cli client.APIClient
3737 insecureTLS bool
38+ plainHTTP bool
3839 progress io.Writer
3940 remoteClient *remote.Client // nil when local
4041 }
@@ -70,8 +71,11 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
7071 if err != nil {
7172 return nil, err
7273 }
73- if resolved != nil && resolved.Token != "" {
74- ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
74+ if resolved != nil {
75+ ops.plainHTTP = strings.HasPrefix(strings.ToLower(resolved.URL), "http://")
76+ if resolved.Token != "" {
77+ ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
78+ }
7579 }
7680 return ops, nil
7781 }
@@ -87,6 +91,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
8791 opts := []registrypush.Option{
8892 registrypush.WithProgress(d.progress),
8993 registrypush.WithInsecureTLS(d.insecureTLS),
94+ registrypush.WithPlainHTTP(d.plainHTTP),
9095 }
9196
9297 if d.remoteClient != nil {
@@ -107,6 +112,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
107112 retryOpts := []registrypush.Option{
108113 registrypush.WithProgress(d.progress),
109114 registrypush.WithInsecureTLS(d.insecureTLS),
115+ registrypush.WithPlainHTTP(d.plainHTTP),
110116 registrypush.WithAuth(authHeader),
111117 }
112118 pusher = registrypush.New(retryOpts...)
@@ -175,7 +181,7 @@ func (d *dockerImageOps) Build(ctx context.Context, args []string) error {
175181 "Podman and other runtimes are not supported for --build.\n" +
176182 "Build the image manually and push with:\n" +
177183 " podman build -t <image> .\n" +
178- " gordon push <image>",
184+ " gordon images push <image>",
179185 )
180186 }
181187 cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
M internal/adapters/in/cli/push_integration_test.go +8 -6
@@ -3,6 +3,7 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "net/http"
78 "net/http/httptest"
89 "strings"
@@ -33,9 +34,13 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
3334 img, err := random.Image(512, 1)
3435 require.NoError(t, err)
3536
37+ registry := strings.TrimPrefix(harness.server.URL, "http://")
38+ versionRef := registry + "/testapp:v1.0.0"
39+ latestRef := registry + "/testapp:latest"
40+
3641 ops := climocks.NewMockpushImageOps(t)
3742 ops.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
38- ops.On("Exists", mock.Anything, mock.Anything).Return(true, nil)
43+ ops.On("Exists", mock.Anything, registry+"/testapp:v1.0.0").Return(true, nil)
3944 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {
4045 pusher := registrypush.New(
4146 registrypush.WithChunkSize(256),
@@ -47,13 +52,10 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
4752 return pusher.Push(ctx, ref)
4853 })
4954
50- registry := strings.TrimPrefix(harness.server.URL, "http://")
51- versionRef := registry + "/testapp:v1.0.0"
52- latestRef := registry + "/testapp:latest"
53-
54- err = tagAndPush(ctx, ops, imagePush{
55+ err = tagAndPush(ctx, io.Discard, ops, imagePush{
5556 Registry: registry,
5657 ImageName: "testapp",
58+ SourceRef: versionRef,
5759 Version: "v1.0.0",
5860 VersionRef: versionRef,
5961 LatestRef: latestRef,
M internal/adapters/in/cli/push_remote_inference_test.go +19 -414
@@ -13,16 +13,14 @@ import (
1313
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
16-
17- "github.com/bnema/gordon/internal/domain"
1816 )
1917
2018 func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
19+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
2220 assert.Equal(t, "myapp", image)
23- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
21+ return []string{"latest"}, http.StatusOK
2422 })
25- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
23+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
2624 assert.Equal(t, "myapp", image)
2725 return nil, http.StatusOK
2826 })
@@ -43,11 +41,11 @@ url = "`+staging.URL+`"
4341 }
4442
4543 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
44+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
45+ return []string{"latest"}, http.StatusOK
4846 })
49- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50- return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
47+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
48+ return []string{"latest"}, http.StatusOK
5149 })
5250
5351 configurePushRemoteInferenceTestEnv(t, `
@@ -73,9 +71,9 @@ func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
7371 })
7472
7573 called := false
76- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
74+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
7775 called = true
78- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
76+ return []string{"latest"}, http.StatusOK
7977 })
8078
8179 configurePushRemoteInferenceTestEnv(t, `
@@ -93,9 +91,9 @@ url = "`+prod.URL+`"
9391
9492 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
9593 called := false
96- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
94+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
9795 called = true
98- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
96+ return []string{"latest"}, http.StatusOK
9997 })
10098
10199 configurePushRemoteInferenceTestEnv(t, `
@@ -111,26 +109,11 @@ url = "`+prod.URL+`"
111109 assert.False(t, called)
112110 }
113111
114-func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116- return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117- })
118-
119- configurePushRemoteInferenceTestEnv(t, `
120-[remotes.prod]
121-url = "`+prod.URL+`"
122-`)
123-
124- resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125- require.NoError(t, err)
126- assert.Nil(t, resolved)
127-}
128-
129112 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
113+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
114+ return []string{"latest"}, http.StatusOK
132115 })
133- broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116+ broken := newTagsTestServer(t, func(image string) ([]string, int) {
134117 return nil, http.StatusInternalServerError
135118 })
136119
@@ -149,199 +132,6 @@ url = "`+broken.URL+`"
149132 assert.Contains(t, err.Error(), "broken")
150133 }
151134
152-func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155- })
156- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157- return nil, http.StatusNotFound
158- })
159-
160- configurePushRemoteInferenceTestEnv(t, `
161-[remotes.prod]
162-url = "`+prod.URL+`"
163-
164-[remotes.staging]
165-url = "`+staging.URL+`"
166-`)
167-
168- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169- require.NoError(t, err)
170- require.NotNil(t, resolved)
171- assert.Equal(t, "prod", resolved.Name)
172-}
173-
174-func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177- })
178- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180- })
181-
182- configurePushRemoteInferenceTestEnv(t, `
183-[remotes.prod]
184-url = "`+prod.URL+`"
185-
186-[remotes.staging]
187-url = "`+staging.URL+`"
188-`)
189-
190- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191- require.Error(t, err)
192- assert.Nil(t, resolved)
193- assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194-}
195-
196-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198- getRoute: func(domainName string) (*domain.Route, int) {
199- return nil, http.StatusNotFound
200- },
201- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202- return &domain.CleanupReport{
203- Domain: domainName,
204- OrphanedEntities: []domain.CleanupOrphanedEntity{{
205- Kind: "route_container",
206- ID: "abc123",
207- Status: "running",
208- }},
209- }, http.StatusOK
210- },
211- })
212- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213- getRoute: func(domainName string) (*domain.Route, int) {
214- return nil, http.StatusNotFound
215- },
216- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218- },
219- })
220-
221- configurePushRemoteInferenceTestEnv(t, `
222-[remotes.prod]
223-url = "`+prod.URL+`"
224-
225-[remotes.staging]
226-url = "`+staging.URL+`"
227-`)
228-
229- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230- require.NoError(t, err)
231- require.NotNil(t, resolved)
232- assert.Equal(t, "prod", resolved.Name)
233-}
234-
235-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237- getRoute: func(domainName string) (*domain.Route, int) {
238- return nil, http.StatusNotFound
239- },
240- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241- return &domain.CleanupReport{
242- Domain: domainName,
243- PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244- }, http.StatusOK
245- },
246- })
247- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248- getRoute: func(domainName string) (*domain.Route, int) {
249- return nil, http.StatusNotFound
250- },
251- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253- },
254- })
255-
256- configurePushRemoteInferenceTestEnv(t, `
257-[remotes.prod]
258-url = "`+prod.URL+`"
259-
260-[remotes.staging]
261-url = "`+staging.URL+`"
262-`)
263-
264- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265- require.NoError(t, err)
266- require.NotNil(t, resolved)
267- assert.Equal(t, "prod", resolved.Name)
268-}
269-
270-func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272- getRoute: func(domainName string) (*domain.Route, int) {
273- return nil, http.StatusNotFound
274- },
275- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276- return nil, http.StatusForbidden
277- },
278- })
279-
280- configurePushRemoteInferenceTestEnv(t, `
281-[remotes.prod]
282-url = "`+prod.URL+`"
283-`)
284-
285- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286- require.Error(t, err)
287- assert.Nil(t, resolved)
288- assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289-}
290-
291-func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
292- prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
293- return []string{"app.example.com"}, http.StatusOK
294- })
295- staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
296- return nil, http.StatusOK
297- })
298-
299- configurePushRemoteInferenceTestEnv(t, `
300-[remotes.prod]
301-url = "`+prod.URL+`"
302-
303-[remotes.staging]
304-url = "`+staging.URL+`"
305-`)
306-
307- resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
308- require.NoError(t, err)
309- require.NotNil(t, resolved)
310- assert.Equal(t, "prod", resolved.Name)
311-}
312-
313-func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
314- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
315- getRoute: func(domainName string) (*domain.Route, int) {
316- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
317- },
318- getAttachmentsConfig: func(target string) ([]string, int) {
319- return nil, http.StatusNotFound
320- },
321- })
322- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
323- getRoute: func(domainName string) (*domain.Route, int) {
324- return nil, http.StatusNotFound
325- },
326- getAttachmentsConfig: func(target string) ([]string, int) {
327- return nil, http.StatusNotFound
328- },
329- })
330-
331- configurePushRemoteInferenceTestEnv(t, `
332-[remotes.prod]
333-url = "`+prod.URL+`"
334-
335-[remotes.staging]
336-url = "`+staging.URL+`"
337-`)
338-
339- resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
340- require.NoError(t, err)
341- require.NotNil(t, resolved)
342- assert.Equal(t, "prod", resolved.Name)
343-}
344-
345135 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346136 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347137 return []string{"latest", "v1.0.0"}, http.StatusOK
@@ -386,205 +176,20 @@ func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
386176
387177 configHome := t.TempDir()
388178 t.Setenv("XDG_CONFIG_HOME", configHome)
389- configPath := filepath.Join(configHome, "gordon", "remotes.toml")
390- require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
391- require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
179+ cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml")
180+ require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755))
181+ require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392182 }
393183
394-func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
395- t.Helper()
396-
397- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398- if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
399- http.NotFound(w, r)
400- return
401- }
402-
403- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
404- if !assert.NoError(t, err) {
405- return
406- }
407-
408- routes, status := handler(image)
409- if status >= http.StatusBadRequest {
410- w.Header().Set("Content-Type", "application/json")
411- w.WriteHeader(status)
412- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
413- return
414- }
415- return
416- }
417-
418- w.Header().Set("Content-Type", "application/json")
419- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
420- "image": image,
421- "routes": routes,
422- })) {
423- return
424- }
425- }))
426- t.Cleanup(server.Close)
427- return server
428-}
429-
430-func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
431- t.Helper()
432- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
433-}
434-
435-type multiAdminProbeHandlers struct {
436- getRoute func(domainName string) (*domain.Route, int)
437- getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
438- getAttachmentsConfig func(target string) ([]string, int)
439- listTags func(repository string) ([]string, int)
440- findAttachmentTarget func(image string) ([]string, int)
441-}
442-
443-func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
184+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
444185 t.Helper()
445-
446186 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447- switch {
448- case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449- handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
450- case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
451- handleGetRouteProbe(t, w, r, handlers.getRoute)
452- case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
453- handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
454- case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
455- handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
456- case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
457- handleListTagsProbe(t, w, r, handlers.listTags)
458- default:
459- http.NotFound(w, r)
460- }
187+ handleListTagsProbe(t, w, r, handler)
461188 }))
462189 t.Cleanup(server.Close)
463190 return server
464191 }
465192
466-func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
467- t.Helper()
468- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
469-}
470-
471-func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
472- t.Helper()
473- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
474-}
475-
476-func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
477- t.Helper()
478- if handler == nil {
479- http.NotFound(w, r)
480- return
481- }
482- domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
483- if !assert.NoError(t, err) {
484- return
485- }
486- route, status := handler(domainName)
487- if status >= http.StatusBadRequest {
488- w.Header().Set("Content-Type", "application/json")
489- w.WriteHeader(status)
490- if status == http.StatusNotFound {
491- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
492- return
493- }
494- return
495- }
496- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
497- return
498- }
499- return
500- }
501- w.Header().Set("Content-Type", "application/json")
502- if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
503- return
504- }
505-}
506-
507-func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508- t.Helper()
509- if handler == nil {
510- http.NotFound(w, r)
511- return
512- }
513- domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514- if !assert.NoError(t, err) {
515- return
516- }
517- report, status := handler(domainName)
518- if status >= http.StatusBadRequest {
519- w.Header().Set("Content-Type", "application/json")
520- w.WriteHeader(status)
521- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522- return
523- }
524- return
525- }
526- w.Header().Set("Content-Type", "application/json")
527- if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528- return
529- }
530-}
531-
532-func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
533- t.Helper()
534- if handler == nil {
535- http.NotFound(w, r)
536- return
537- }
538- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
539- if !assert.NoError(t, err) {
540- return
541- }
542- targets, status := handler(image)
543- if status >= http.StatusBadRequest {
544- w.Header().Set("Content-Type", "application/json")
545- w.WriteHeader(status)
546- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
547- return
548- }
549- return
550- }
551- w.Header().Set("Content-Type", "application/json")
552- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
553- return
554- }
555-}
556-
557-func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
558- t.Helper()
559- if handler == nil {
560- http.NotFound(w, r)
561- return
562- }
563- target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
564- if !assert.NoError(t, err) {
565- return
566- }
567- images, status := handler(target)
568- if status >= http.StatusBadRequest {
569- w.Header().Set("Content-Type", "application/json")
570- w.WriteHeader(status)
571- if status == http.StatusNotFound {
572- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
573- return
574- }
575- return
576- }
577- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
578- return
579- }
580- return
581- }
582- w.Header().Set("Content-Type", "application/json")
583- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
584- return
585- }
586-}
587-
588193 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589194 t.Helper()
590195 if handler == nil {
M internal/adapters/in/cli/push_test.go +5 -110 collapsed to keep the page light
A internal/adapters/in/cli/remote/apps.go +306 -0 collapsed to keep the page light
A internal/adapters/in/cli/remote/apps_test.go +245 -0 collapsed to keep the page light
M internal/adapters/in/cli/remote/client.go +50 -540 collapsed to keep the page light
M internal/adapters/in/cli/remote/client_test.go +18 -167 collapsed to keep the page light
A internal/adapters/in/cli/remote/local.go +106 -0 collapsed to keep the page light
A internal/adapters/in/cli/remote/local_test.go +210 -0 collapsed to keep the page light
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 +48 -104 collapsed to keep the page light
M internal/adapters/in/cli/remotes.go +1 -1
@@ -296,7 +296,7 @@ without needing to specify --remote.
296296
297297 Examples:
298298 gordon remotes use prod
299- gordon routes list # Uses prod remote automatically`,
299+ gordon daemon status # Uses prod remote automatically`,
300300 Args: cobra.ExactArgs(1),
301301 RunE: func(cmd *cobra.Command, args []string) error {
302302 name := args[0]
D internal/adapters/in/cli/restart.go +0 -58 collapsed to keep the page light
M internal/adapters/in/cli/root.go +57 -255 collapsed to keep the page light
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
D internal/adapters/in/cli/secrets.go +0 -369 collapsed to keep the page light
M internal/adapters/in/cli/serve.go +3 -3
@@ -10,19 +10,19 @@ import (
1010
1111 // newServeCmd creates the serve command.
1212 func newServeCmd() *cobra.Command {
13- var configPath string
13+ var cliConfigPath string
1414
1515 cmd := &cobra.Command{
1616 Use: "serve",
1717 Short: "Start the Gordon server",
1818 Long: `Start the Gordon server, including the registry and proxy components.`,
1919 RunE: func(cmd *cobra.Command, args []string) error {
20- return app.Run(context.Background(), configPath)
20+ return app.Run(context.Background(), cliConfigPath)
2121 },
2222 }
2323
2424 // Add flags
25- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
25+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
2626
2727 return cmd
2828 }
A internal/adapters/in/cli/status.go +89 -0 collapsed to keep the page light
M internal/adapters/in/cli/tls.go +2 -13
@@ -16,24 +16,13 @@ import (
1616 var tlsResolveControlPlane = resolveControlPlane
1717
1818 func newTLSCmd() *cobra.Command {
19- cmd := &cobra.Command{
20- Use: "tls",
21- Short: "Inspect TLS certificate status",
22- }
23-
24- cmd.AddCommand(newTLSStatusCmd())
25-
26- return cmd
27-}
28-
29-func newTLSStatusCmd() *cobra.Command {
3019 var jsonOut bool
3120
3221 cmd := &cobra.Command{
33- Use: "status",
22+ Use: "tls",
3423 Short: "Show public TLS certificate status",
3524 RunE: func(cmd *cobra.Command, _ []string) error {
36- handle, err := tlsResolveControlPlane(configPath)
25+ handle, err := tlsResolveControlPlane(cliConfigPath)
3726 if err != nil {
3827 return err
3928 }
D internal/adapters/in/cli/traffic.go +0 -12 collapsed to keep the page light
M internal/adapters/in/cli/traffic_status.go +3 -3 collapsed to keep the page light
M internal/adapters/in/cli/traffic_test.go +6 -32 collapsed to keep the page light
M internal/adapters/in/cli/ui_adoption_test.go +3 -127 collapsed to keep the page light
M internal/adapters/in/cli/volumes.go +40 -26 collapsed to keep the page light
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 +101 -1472 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps.go +670 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps_test.go +683 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired.go +49 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired_test.go +62 -0 collapsed to keep the page light
M internal/adapters/in/http/admin/handler_test.go +139 -2078 large diff (2217 lines changed): collapsed
M internal/adapters/in/http/admin/handler_volumes.go +6 -0 collapsed to keep the page light
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 +148 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/local_authority_test.go +146 -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
M internal/adapters/in/traffic/manager.go +8 -0 collapsed to keep the page light
M internal/adapters/in/traffic/udp.go +188 -87 collapsed to keep the page light
M internal/adapters/in/traffic/udp_test.go +511 -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 +76 -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 +1662 -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 +617 -0 collapsed to keep the page light
A internal/adapters/out/docker/live_network_probe_test.go +255 -0 collapsed to keep the page light
A internal/adapters/out/docker/live_runtime_test.go +208 -0 collapsed to keep the page light
A internal/adapters/out/docker/live_shared_network_test.go +65 -0 collapsed to keep the page light
A internal/adapters/out/docker/log_stream.go +133 -0 collapsed to keep the page light
A internal/adapters/out/docker/log_stream_test.go +94 -0 collapsed to keep the page light
A internal/adapters/out/docker/network_probe.go +439 -0 collapsed to keep the page light
A internal/adapters/out/docker/network_probe_test.go +174 -0 collapsed to keep the page light
M internal/adapters/out/docker/runtime.go +429 -91 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 +132 -0 collapsed to keep the page light
A internal/adapters/out/docker/runtime_devices_test.go +124 -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
D internal/adapters/out/domainsecrets/helpers_test.go +0 -25 collapsed to keep the page light
D internal/adapters/out/domainsecrets/pass_store.go +0 -934 collapsed to keep the page light
D internal/adapters/out/domainsecrets/pass_store_test.go +0 -330 collapsed to keep the page light
D internal/adapters/out/domainsecrets/store.go +0 -554 collapsed to keep the page light
D internal/adapters/out/domainsecrets/store_test.go +0 -432 collapsed to keep the page light
D internal/adapters/out/envloader/file.go +0 -253 collapsed to keep the page light
D internal/adapters/out/envloader/file_test.go +0 -368 collapsed to keep the page light
D internal/adapters/out/envloader/pass_loader.go +0 -117 collapsed to keep the page light
D internal/adapters/out/envloader/pass_loader_test.go +0 -108 collapsed to keep the page light
M internal/adapters/out/eventbus/inmemory.go +4 -11 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 +38 -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/adapters/out/telemetry/logs.go +169 -0 collapsed to keep the page light
A internal/adapters/out/telemetry/logs_test.go +112 -0 collapsed to keep the page light
M internal/adapters/out/telemetry/metrics.go +49 -6 collapsed to keep the page light
A internal/adapters/out/telemetry/metrics_test.go +67 -0 collapsed to keep the page light
M internal/adapters/out/telemetry/provider.go +19 -1 collapsed to keep the page light
A internal/app/app_deploy_handler_lifecycle_test.go +138 -0 collapsed to keep the page light
A internal/app/app_devices.go +107 -0 collapsed to keep the page light
A internal/app/app_devices_test.go +118 -0 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_mounts.go +70 -0 collapsed to keep the page light
A internal/app/app_mounts_test.go +103 -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/app_wiring.go +252 -0 collapsed to keep the page light
A internal/app/auth_secrets.go +300 -0 collapsed to keep the page light
A internal/app/backup_wiring.go +250 -0 collapsed to keep the page light
A internal/app/config.go +455 -0 collapsed to keep the page light
A internal/app/config_validation.go +13 -0 collapsed to keep the page light
A internal/app/credentials.go +195 -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
A internal/app/http_edge.go +509 -0 collapsed to keep the page light
M internal/app/kernel.go +37 -19 collapsed to keep the page light
M internal/app/kernel_test.go +56 -1 collapsed to keep the page light
A internal/app/listeners.go +418 -0 collapsed to keep the page light
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
D internal/app/migrate_env.go +0 -228 collapsed to keep the page light
D internal/app/migrate_env_test.go +0 -214 collapsed to keep the page light
M internal/app/paths.go +11 -0 collapsed to keep the page light
A internal/app/paths_test.go +47 -0 collapsed to keep the page light
A internal/app/pidfile.go +181 -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
A internal/app/reload.go +353 -0 collapsed to keep the page light
M internal/app/run.go +152 -3811 large diff (3963 lines changed): collapsed
A internal/app/run_app_lifecycle_test.go +172 -0 collapsed to keep the page light
M internal/app/run_entrypoint_migration_test.go +20 -0 collapsed to keep the page light
A internal/app/run_example_config_test.go +21 -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 +278 -160 collapsed to keep the page light
A internal/app/run_standalone_services_test.go +40 -0 collapsed to keep the page light
A internal/app/schedulers.go +224 -0 collapsed to keep the page light
A internal/app/services.go +707 -0 collapsed to keep the page light
A internal/app/shutdown.go +187 -0 collapsed to keep the page light
M internal/app/start_proxy_servers_test.go +48 -13 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 +151 -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 +98 -0 collapsed to keep the page light
A internal/boundaries/in/mocks/mock_app_service.go +1019 -0 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_auth_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_backup_service.go +74 -127 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_config_service.go +166 -1310 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_container_service.go +39 -919 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_health_service.go +10 -60 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_http_prober.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_log_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_proxy_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_public_tls_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_registry_service.go +38 -17 collapsed to keep the page light
D internal/boundaries/in/mocks/mock_secret_service.go +0 -515 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_traffic_status_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_volume_backup_service.go +40 -25 collapsed to keep the page light
M internal/boundaries/in/mocks/mock_volume_service.go +10 -1 collapsed to keep the page light
M internal/boundaries/in/registry.go +2 -2 collapsed to keep the page light
D internal/boundaries/in/secrets.go +0 -39 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 +179 -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
D internal/boundaries/out/domain_secrets.go +0 -39 collapsed to keep the page light
D internal/boundaries/out/envloader.go +0 -16 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/logs.go +24 -0 collapsed to keep the page light
A internal/boundaries/out/metrics.go +18 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_state.go +1551 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_state_reader.go +248 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_app_traffic_refresher.go +224 -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 +58 -43 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_blob_storage.go +164 -74 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_certificate_authority.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_certificate_store.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_cloudflare_zone_resolver.go +10 -1 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_container_log_streamer.go +118 -0 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_container_log_writer.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_container_runtime.go +444 -97 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_domain_secret_store.go +0 -513 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_env_loader.go +0 -223 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_event_bus.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_event_handler.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_event_publisher.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_event_subscriber.go +10 -1 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_gc_barrier.go +172 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_gc_lease.go +78 -0 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_http_challenge_sink.go +10 -1 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_image_resolver.go +113 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_log_exporter.go +94 -0 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_manifest_storage.go +10 -1 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_metrics.go +198 -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 +110 -0 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_prune_runtime.go +224 -0 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_public_certificate_issuer.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_rate_limiter.go +10 -1 collapsed to keep the page light
D internal/boundaries/out/mocks/mock_route_checker.go +0 -138 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_secret_provider.go +10 -1 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_secret_resolver.go +10 -1 collapsed to keep the page light
A internal/boundaries/out/mocks/mock_secret_writer.go +167 -0 collapsed to keep the page light
M internal/boundaries/out/mocks/mock_token_store.go +10 -1 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 +41 -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_bind_policy.go +189 -0 collapsed to keep the page light
A internal/domain/app_bind_policy_test.go +258 -0 collapsed to keep the page light
A internal/domain/app_bind_test.go +111 -0 collapsed to keep the page light
A internal/domain/app_device_policy.go +165 -0 collapsed to keep the page light
A internal/domain/app_device_policy_test.go +188 -0 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 +50 -0 collapsed to keep the page light
A internal/domain/app_reservations.go +142 -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_reservations_visibility_test.go +37 -0 collapsed to keep the page light
A internal/domain/app_spec.go +1204 -0 collapsed to keep the page light
A internal/domain/app_spec_test.go +179 -0 collapsed to keep the page light
A internal/domain/app_state.go +346 -0 collapsed to keep the page light
A internal/domain/app_visibility_diff_test.go +39 -0 collapsed to keep the page light
A internal/domain/app_visibility_test.go +191 -0 collapsed to keep the page light
M internal/domain/auth.go +8 -7 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 +48 -10 collapsed to keep the page light
A internal/domain/container_network_probe.go +89 -0 collapsed to keep the page light
A internal/domain/container_network_probe_test.go +69 -0 collapsed to keep the page light
M internal/domain/env.go +2 -74 collapsed to keep the page light
M internal/domain/env_test.go +0 -176 collapsed to keep the page light
D internal/domain/envfile.go +0 -47 collapsed to keep the page light
M internal/domain/errors.go +53 -1 collapsed to keep the page light
M internal/domain/event.go +3 -47 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 +219 -0 collapsed to keep the page light
A internal/domain/image_policy_test.go +127 -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 +30 -7 collapsed to keep the page light
M internal/domain/labels_test.go +0 -3 collapsed to keep the page light
A internal/domain/logs.go +67 -0 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/route.go +1 -1 collapsed to keep the page light
M internal/domain/volumes.go +3 -0 collapsed to keep the page light
M internal/testutils/fixtures/configs/basic.toml +0 -4 collapsed to keep the page light
M internal/testutils/helpers.go +0 -4 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 +784 -0 collapsed to keep the page light
A internal/usecase/apps/app_service_test.go +123 -0 collapsed to keep the page light
A internal/usecase/apps/bind_policy_test.go +133 -0 collapsed to keep the page light
A internal/usecase/apps/deploy_lifecycle_test.go +387 -0 collapsed to keep the page light
A internal/usecase/apps/device_policy_test.go +130 -0 collapsed to keep the page light
A internal/usecase/apps/managed_count.go +42 -0 collapsed to keep the page light
A internal/usecase/apps/managed_count_test.go +92 -0 collapsed to keep the page light
A internal/usecase/apps/mocks_test.go +106 -0 collapsed to keep the page light
A internal/usecase/apps/service.go +413 -0 collapsed to keep the page light
A internal/usecase/apps/service_scope_test.go +145 -0 collapsed to keep the page light
A internal/usecase/apps/service_test.go +375 -0 collapsed to keep the page light
A internal/usecase/apptraffic/activate.go +220 -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 +179 -0 collapsed to keep the page light
A internal/usecase/apptraffic/projection_test.go +225 -0 collapsed to keep the page light
A internal/usecase/apptraffic/projection_visibility_test.go +56 -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 +46 -3771 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/backend_binds.go +156 -0 collapsed to keep the page light
A internal/usecase/deployment/bind_policy_internal_test.go +329 -0 collapsed to keep the page light
A internal/usecase/deployment/candidate.go +369 -0 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/crash_recovery_test.go +664 -0 collapsed to keep the page light
A internal/usecase/deployment/deploy_mock_test.go +836 -0 collapsed to keep the page light
A internal/usecase/deployment/deploy_unchanged_test.go +264 -0 collapsed to keep the page light
A internal/usecase/deployment/device_policy_internal_test.go +305 -0 collapsed to keep the page light
A internal/usecase/deployment/diagnostics.go +77 -0 collapsed to keep the page light
A internal/usecase/deployment/execute.go +872 -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/image_policy_internal_test.go +75 -0 collapsed to keep the page light
A internal/usecase/deployment/internal_readiness_test.go +363 -0 collapsed to keep the page light
A internal/usecase/deployment/interrupted.go +244 -0 collapsed to keep the page light
A internal/usecase/deployment/lifecycle.go +837 -0 collapsed to keep the page light
A internal/usecase/deployment/monitor.go +851 -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/preflight_gate_test.go +341 -0 collapsed to keep the page light
A internal/usecase/deployment/probes.go +69 -0 collapsed to keep the page light
A internal/usecase/deployment/publication_internal_test.go +160 -0 collapsed to keep the page light
A internal/usecase/deployment/readiness.go +486 -0 collapsed to keep the page light
A internal/usecase/deployment/readiness_internal_test.go +78 -0 collapsed to keep the page light
A internal/usecase/deployment/reconcile_internal_test.go +156 -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/removals.go +122 -0 collapsed to keep the page light
A internal/usecase/deployment/restart_inplace_test.go +163 -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 +204 -0 collapsed to keep the page light
A internal/usecase/deployment/sequential_replacement_test.go +745 -0 collapsed to keep the page light
A internal/usecase/deployment/service.go +958 -0 collapsed to keep the page light
A internal/usecase/deployment/service_test.go +123 -0 collapsed to keep the page light
A internal/usecase/deployment/traffic_failure_test.go +138 -0 collapsed to keep the page light
A internal/usecase/deployment/two_phase_deploy_test.go +459 -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
A internal/usecase/logexport/access.go +94 -0 collapsed to keep the page light
A internal/usecase/logexport/access_test.go +104 -0 collapsed to keep the page light
A internal/usecase/logexport/collector.go +218 -0 collapsed to keep the page light
A internal/usecase/logexport/collector_test.go +298 -0 collapsed to keep the page light
M internal/usecase/logs/service.go +51 -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 +68 -176 collapsed to keep the page light
M internal/usecase/registry/service_test.go +115 -60 collapsed to keep the page light
D internal/usecase/secrets/service.go +0 -348 collapsed to keep the page light
D internal/usecase/secrets/service_test.go +0 -662 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 main.go +3 -2 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
M pkg/version/version.go +8 -2 collapsed to keep the page light
A pkg/version/version_test.go +23 -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 +11 -45 collapsed to keep the page light
M wiki/guides/remote-cli.md +34 -480 collapsed to keep the page light
M wiki/guides/secrets-pass.md +6 -6 collapsed to keep the page light
M wiki/guides/secrets-sops.md +1 -11 collapsed to keep the page light
M wiki/guides/secure-vps-setup.md +1 -1 collapsed to keep the page light
M wiki/tutorials/first-deploy.md +4 -4 collapsed to keep the page light
M wiki/tutorials/postgres-service.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