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:
5 branches: [main]5 branches: [main]
6 pull_request:6 pull_request:
7 7
8+permissions:
9+ contents: read
10+
8 jobs:11 jobs:
9 changes:12 changes:
10 name: Detect changes13 name: Detect changes
@@ -13,6 +16,7 @@ jobs:
13 pull-requests: read16 pull-requests: read
14 outputs:17 outputs:
15 go: ${{ steps.filter.outputs.go }}18 go: ${{ steps.filter.outputs.go }}
19+ installer: ${{ steps.filter.outputs.installer }}
16 steps:20 steps:
17 - uses: actions/checkout@v621 - uses: actions/checkout@v6
18 - uses: dorny/paths-filter@v322 - uses: dorny/paths-filter@v3
@@ -26,6 +30,22 @@ jobs:
26 - '.golangci.yml'30 - '.golangci.yml'
27 - 'Makefile'31 - 'Makefile'
28 - '.github/workflows/**'32 - '.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
29 49
30 lint:50 lint:
31 name: Lint51 name: Lint
@@ -38,14 +58,7 @@ jobs:
38 - uses: actions/setup-go@v558 - uses: actions/setup-go@v5
39 with:59 with:
40 go-version: "1.27.x"60 go-version: "1.27.x"
41-61+ cache: true
42- - name: Cache Go modules
43- uses: actions/cache@v5
44- with:
45- path: ~/go/pkg/mod
46- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
47- restore-keys: |
48- ${{ runner.os }}-go-mod-
49 62
50 - name: Run golangci-lint63 - name: Run golangci-lint
51 uses: golangci/golangci-lint-action@v964 uses: golangci/golangci-lint-action@v9
@@ -63,22 +76,7 @@ jobs:
63 - uses: actions/setup-go@v576 - uses: actions/setup-go@v5
64 with:77 with:
65 go-version: "1.27.x"78 go-version: "1.27.x"
66-79+ cache: true
67- - name: Cache Go modules
68- uses: actions/cache@v5
69- with:
70- path: ~/go/pkg/mod
71- key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
72- restore-keys: |
73- ${{ runner.os }}-go-mod-
74-
75- - name: Cache Go build cache
76- uses: actions/cache@v5
77- with:
78- path: ~/.cache/go-build
79- key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
80- restore-keys: |
81- ${{ runner.os }}-go-build-
82 80
83 - name: Run tests81 - name: Run tests
84 run: go test -race -v ./...82 run: go test -race -v ./...
M .goreleaser.yaml +1 -1
@@ -23,7 +23,7 @@ builds:
23 env:23 env:
24 - CGO_ENABLED=024 - CGO_ENABLED=0
25 ldflags:25 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}}
27 27
28 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)28 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)
29 # dockers:29 # dockers:
M .mockery.yaml +14 -6
@@ -17,20 +17,28 @@ packages:
17 EventSubscriber:17 EventSubscriber:
18 EventHandler:18 EventHandler:
19 SecretProvider:19 SecretProvider:
20- EnvLoader:
21 ContainerLogWriter:20 ContainerLogWriter:
22- AttachmentConfigProvider:21+ Metrics:
22+ LogExporter:
23+ ContainerLogStreamer:
23 TokenStore:24 TokenStore:
24- DomainSecretStore:25+ AppState:
26+ AppStateReader:
27+ ImageResolver:
28+ SecretWriter:
25 RateLimiter:29 RateLimiter:
26 BackupStorage:30 BackupStorage:
27- RouteChecker:
28 HTTPChallengeSink:31 HTTPChallengeSink:
29 PublicCertificateIssuer:32 PublicCertificateIssuer:
30 CertificateStore:33 CertificateStore:
31 SecretResolver:34 SecretResolver:
32 CloudflareZoneResolver:35 CloudflareZoneResolver:
33 CertificateAuthority:36 CertificateAuthority:
37+ AppTrafficRefresher:
38+ PruneProtectionStore:
39+ PruneRuntime:
40+ GCBarrier:
41+ GCLease:
34 github.com/bnema/gordon/internal/boundaries/in:42 github.com/bnema/gordon/internal/boundaries/in:
35 interfaces:43 interfaces:
36 ContainerService:44 ContainerService:
@@ -42,12 +50,12 @@ packages:
42 AuthService:50 AuthService:
43 HealthService:51 HealthService:
44 HTTPProber:52 HTTPProber:
45- SecretService:
46 LogService:53 LogService:
47 VolumeService:54 VolumeService:
48 PublicTLSService:55 PublicTLSService:
49 TrafficStatusService:56 TrafficStatusService:
50- StandaloneServiceService:57+ AppService:
58+ AppReconciler:
51 # Exception: pushImageOps is a CLI-local interface, not a boundary port.59 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
52 # Mocked here because it abstracts Docker SDK calls that require a running60 # Mocked here because it abstracts Docker SDK calls that require a running
53 # daemon, making unit/integration tests impractical without a test double.61 # daemon, making unit/integration tests impractical without a test double.
M AGENTS.md +3 -4
@@ -111,10 +111,9 @@ CLI commands do NOT use zerowrap — they use `cliWriteLine`/`cliWritef` for out
111 111
112 ### ControlPlane Pattern112 ### ControlPlane Pattern
113 113
114-`ControlPlane` interface (`controlplane.go`) abstracts local vs remote operations.114+`ControlPlane` interface (`controlplane.go`) is the seam CLI commands depend on.
115-- `controlplane_remote.go` — delegates to `remote.Client` HTTP methods.115+- `*remote.Client` implements it for both the explicit remote and the local admin socket.
116-- `controlplane_local.go` — calls service interfaces directly.116+- Tests use the mockery mock in `cli/mocks/` — run `mockery` after adding interface methods.
117-- Test fakes in `push_test.go` — update when adding interface methods.
118 117
119 ### HTTP Admin Handlers118 ### HTTP Admin Handlers
120 119
M Dockerfile +7 -1
@@ -5,6 +5,11 @@ ARG BUILDPLATFORM
5 ARG VERSION=dev5 ARG VERSION=dev
6 ARG COMMIT=unknown6 ARG COMMIT=unknown
7 ARG BUILD_DATE=unknown7 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
8 13
9 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder14 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
10 15
@@ -13,6 +18,7 @@ ARG TARGETARCH=amd64
13 ARG VERSION18 ARG VERSION
14 ARG COMMIT19 ARG COMMIT
15 ARG BUILD_DATE20 ARG BUILD_DATE
21+ARG DIRTY
16 22
17 RUN apk add --no-cache git ca-certificates tzdata23 RUN apk add --no-cache git ca-certificates tzdata
18 24
@@ -25,7 +31,7 @@ COPY . .
25 31
26 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \32 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
27 -trimpath \33 -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}" \
29 -o /gordon ./main.go35 -o /gordon ./main.go
30 36
31 FROM alpine:3.2237 FROM alpine:3.22
M Makefile +8 -2
@@ -10,12 +10,17 @@ ENGINE := podman
10 VERSION := $(shell git describe --tags --always --dirty)10 VERSION := $(shell git describe --tags --always --dirty)
11 COMMIT := $(shell git rev-parse --short HEAD)11 COMMIT := $(shell git rev-parse --short HEAD)
12 BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')12 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)
13 17
14 # Build flags18 # Build flags
15 LDFLAGS := -s -w \19 LDFLAGS := -s -w \
16 -X main.version=$(VERSION) \20 -X main.version=$(VERSION) \
17 -X main.commit=$(COMMIT) \21 -X main.commit=$(COMMIT) \
18- -X main.date=$(BUILD_DATE)22+ -X main.date=$(BUILD_DATE) \
23+ -X main.dirty=$(DIRTY)
19 24
20 # Architectures25 # Architectures
21 ARCHS := amd64 arm6426 ARCHS := amd64 arm64
@@ -83,7 +88,7 @@ build: ## Build binaries for linux (amd64 and arm64)
83 @echo "Building Go binaries..."88 @echo "Building Go binaries..."
84 @mkdir -p $(DIST_DIR)89 @mkdir -p $(DIST_DIR)
85 @rm -f $(DIST_DIR)/*90 @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)"
87 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go92 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
88 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go93 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
89 @echo "Go binaries built successfully"94 @echo "Go binaries built successfully"
@@ -105,6 +110,7 @@ build-push: ## Build and push Docker images
105 --build-arg VERSION="$(VERSION)" \110 --build-arg VERSION="$(VERSION)" \
106 --build-arg COMMIT="$(COMMIT)" \111 --build-arg COMMIT="$(COMMIT)" \
107 --build-arg BUILD_DATE="$(BUILD_DATE)" \112 --build-arg BUILD_DATE="$(BUILD_DATE)" \
113+ --build-arg DIRTY="$(DIRTY)" \
108 -t $(REPO):$(TAG)-$$arch .; \114 -t $(REPO):$(TAG)-$$arch .; \
109 $(ENGINE) push $(REPO):$(TAG)-$$arch; \115 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
110 done116 done
M README.md +79 -51
@@ -2,57 +2,79 @@
2 2
3 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)3 [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4 4
5-Self-hosted container deployment. Push an image, Gordon routes it to the web.5+Self-hosted container deployment. Push an image, declare an app, deploy it.
6 6
7 - Website: https://bnema.dev/gordon7 - Website: https://bnema.dev/gordon
8-- Documentation: [Docs](https://gordon.bnema.dev/docs) | [Wiki](https://gordon.bnema.dev/wiki)8+- Documentation: [Docs](https://bnema.dev/gordon/docs) | [Wiki](https://bnema.dev/gordon/wiki)
9 - Discuss: [GitHub Discussions](https://github.com/bnema/gordon/discussions)9 - Discuss: [GitHub Discussions](https://github.com/bnema/gordon/discussions)
10 10
11 ---11 ---
12 12
13 ## What is Gordon?13 ## What is Gordon?
14 14
15-Gordon is a private container registry and HTTP reverse proxy for your VPS. Push a container image that exposes a web port — Gordon deploys it with zero downtime.15+Gordon is a private container registry, an app runtime, and a reverse proxy for your VPS.
16+
17+The flow is explicit: build and push an image, declare the app in a standalone TOML file, apply it, then deploy. Push transfers OCI content only — it never deploys.
16 18
17 ## Quick Start19 ## Quick Start
18 20
19 ```bash21 ```bash
20-# Install22+# Install the latest stable release to ~/.local/bin
21-curl -fsSL https://gordon.bnema.dev/install.sh | sh23+curl -fsSL https://bnema.dev/gordon/install | sh
22 24
23-# Start the server25+# Start the server (restart your shell first if the installer updated PATH)
24 gordon serve26 gordon serve
25 ```27 ```
26 28
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.
28 34
29 ## Deploy with the CLI35 ## Deploy with the CLI
30 36
31-Build locally, push directly to your Gordon server:37+Build locally, push to your Gordon server, declare the app, deploy:
32 38
33 ```bash39 ```bash
34-# Push using an explicit domain override40+# Push the image (OCI transfer only, never deploys)
35-gordon push myapp:latest --domain app.example.com41+gordon 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
36 45
37-# Or add a route manually, then deploy46+# Activate it
38-gordon routes add app.example.com myapp:latest47+gordon apps deploy blog --remote prod
39-gordon routes deploy app.example.com
40 48
41 # Check status49 # Check status
42-gordon status50+gordon daemon status
51+gordon apps status blog --remote prod
43 ```52 ```
44 53
45-Roll back, restart, or manage secrets — all from the command line:54+Minimal app file (`blog.toml`):
55+
56+```toml
57+name = "blog"
58+
59+[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:
46 68
47 ```bash69 ```bash
48-gordon pin app.example.com # Pin to a specific image tag70+gordon apps restart blog --remote prod # Restart from pinned digests
49-gordon restart app.example.com # Restart the container71+gordon apps stop blog --remote prod # Stop, preserve all data
50-gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret12372+gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
51 ```73 ```
52 74
53 ## Deploy from CI/CD75 ## Deploy from CI/CD
54 76
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.
56 78
57 ### GitHub Actions79 ### GitHub Actions
58 80
@@ -64,17 +86,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on
64 password: ${{ secrets.GORDON_TOKEN }}86 password: ${{ secrets.GORDON_TOKEN }}
65 ```87 ```
66 88
67-### Docker CLI89+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
68 92
69 ```bash93 ```bash
70-docker login registry.mydomain.com94+docker login gordon.mydomain.com
71-docker build -t registry.mydomain.com/myapp:v1.0.0 .95+docker build -t gordon.mydomain.com/myapp:v1.0.0 .
72-docker push registry.mydomain.com/myapp:v1.0.096+docker push gordon.mydomain.com/myapp:v1.0.0
73-# -> Deployed automatically97+# Then, with the Gordon binary:
98+gordon apps apply --file blog.toml --remote prod
99+gordon apps deploy blog --remote prod
74 ```100 ```
75 101
76-See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options.
77-
78 ## CLI Commands102 ## CLI Commands
79 103
80 ### Server104 ### Server
@@ -82,36 +106,41 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
82 | Command | Description |106 | Command | Description |
83 |---------|-------------|107 |---------|-------------|
84 | `gordon serve` | Start the Gordon server |108 | `gordon serve` | Start the Gordon server |
85-| `gordon status` | Show server and route health |109+| `gordon daemon status` | Show server and app fleet status |
86-| `gordon config show` | Display server configuration |110+| `gordon daemon logs` | Show Gordon process logs |
111+| `gordon daemon reload` | Reload installation configuration |
112+| `gordon daemon config show` | Display installation configuration |
87 113
88-### Deployment114+### Applications
89 115
90 | Command | Description |116 | Command | Description |
91 |---------|-------------|117 |---------|-------------|
92-| `gordon push [image]` | Tag, push, and optionally deploy an image |118+| `gordon apps apply --file FILE` | Validate and persist an app manifest |
93-| `gordon routes list` | List all routes |119+| `gordon apps deploy APP` | Activate an app revision |
94-| `gordon routes add <domain> <image>` | Create or update a route |120+| `gordon apps list` | List applications |
95-| `gordon routes remove <domain>` | Remove a route |121+| `gordon apps show APP` | Show desired and active state |
96-| `gordon routes deploy <domain>` | Redeploy a route |122+| `gordon apps status APP` | Show effective vs observed state |
97-| `gordon pin <domain>` | Pin a route to a specific image tag |123+| `gordon apps logs APP` | Show logs for an app service |
98-| `gordon restart <domain>` | Restart a route container |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) |
99 128
100 ### Images & Registry129 ### Images & Registry
101 130
102 | Command | Description |131 | Command | Description |
103 |---------|-------------|132 |---------|-------------|
133+| `gordon images push [image]` | Tag and push an image (never deploys) |
104 | `gordon images list` | List runtime and registry images |134 | `gordon images list` | List runtime and registry images |
105 | `gordon images prune` | Clean up dangling images and old tags |135 | `gordon images prune` | Clean up dangling images and old tags |
106 | `gordon images tags <repo>` | List registry tags for a repository |136 | `gordon images tags <repo>` | List registry tags for a repository |
107 137
108-### Secrets & Config138+### Secrets
109 139
110 | Command | Description |140 | Command | Description |
111 |---------|-------------|141 |---------|-------------|
112-| `gordon secrets list <domain>` | List secrets for a route |142+| `gordon apps secrets list APP` | List app secret names |
113-| `gordon secrets set <domain> KEY=VAL` | Set secrets |143+| `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |
114-| `gordon secrets remove <domain> <key>` | Remove a secret |
115 144
116 ### Remotes & Auth145 ### Remotes & Auth
117 146
@@ -125,23 +154,22 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
125 154
126 ## Features155 ## Features
127 156
128-- Private Docker registry on your VPS157+- Private Docker/Podman registry on your VPS
158+- Declarative apps: one TOML file per app, explicit apply then deploy
129 - Domain-to-container routing through a smart TCP edge reverse proxy159 - Domain-to-container routing through a smart TCP edge reverse proxy
130-- Automatic deployment on image push160+- Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish)
131-- Auto-routing from image labels161+- Remote CLI management (daemon is the sole writer)
132-- Remote CLI management162+- Declarative per-service volumes, retained across lifecycle operations
133-- Zero downtime updates163+- Per-service secrets in pass, app-wide public env in the manifest
134-- Persistent volumes from Dockerfile VOLUME directives164+- Per-app private networks plus opt-in shared networks
135-- Environment variable management with secrets support165+- Single binary
136-- Network isolation per application
137-- Single binary, ~15MB RAM
138 166
139 > [!NOTE]167 > [!NOTE]
140-> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.168+> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements. Use Gordon's ownership-aware volume commands; runtime commands such as `docker volume prune` bypass Gordon's retention checks.
141 169
142 ## Documentation170 ## Documentation
143 171
144-Full documentation at **[gordon.bnema.dev](https://bnema.dev/gordon)**172+Full documentation at **[bnema.dev/gordon](https://bnema.dev/gordon)**
145 173
146 - [Docs](https://bnema.dev/gordon/docs) — Installation, configuration, CLI reference174 - [Docs](https://bnema.dev/gordon/docs) — Installation, configuration, CLI reference
147 - [Wiki](https://bnema.dev/gordon/wiki) — Tutorials, guides, and examples175 - [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`
135 135
136 Registry scopes: `push`, `pull`, `push,pull`136 Registry scopes: `push`, `pull`, `push,pull`
137 137
138-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`138+Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`
139 139
140 Combine scopes with commas:140 Combine scopes with commas:
141 141
@@ -271,14 +271,12 @@ Usage:
271 | `pull` | Pull images from registry |271 | `pull` | Pull images from registry |
272 | `push,pull` | Both push and pull (default) |272 | `push,pull` | Both push and pull (default) |
273 | `admin:*:*` | Full admin access |273 | `admin:*:*` | Full admin access |
274-| `admin:routes:read` | Read-only routes access |274+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
275-| `admin:routes:write` | Routes write access |275+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
276 | `admin:config:read` | Read-only config access |276 | `admin:config:read` | Read-only config access |
277 | `admin:config:write` | Config write access |277 | `admin:config:write` | Config write access |
278 | `admin:status:read` | Read-only status/health |278 | `admin:status:read` | Read-only status/health |
279 | `admin:logs:read` | Read-only logs access |279 | `admin:logs:read` | Read-only logs access |
280-| `admin:secrets:read` | List secret keys |
281-| `admin:secrets:write` | Set/delete secrets |
282 280
283 ## Token Expiry Formats281 ## Token Expiry Formats
284 282
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 @@
1 # Backup Command1 # Backup Command
2 2
3-Manage database backups and volume backups.3+Manage app database backups and app volume backups.
4 4
5-## gordon backups databases5+Backups are identified by app, service, and the declared resource. Domains are
6+routing addresses and are never a backup identity.
7+
8+## Declaring backup targets
9+
10+A service declares its databases and volumes in the app manifest, and lists
11+which of them are backed up:
12+
13+```toml
14+[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
6 39
7 Database backups are logical PostgreSQL backups made with `pg_dump`.40 Database backups are logical PostgreSQL backups made with `pg_dump`.
8 41
9 ```bash42 ```bash
10-gordon backups databases <subcommand>43+gordon backups <subcommand>
11 ```44 ```
12 45
13 Subcommands:46 Subcommands:
14 47
15-- `list [domain]` - List database backups48+- `list [app]` - List stored backups, for every app or one app
16-- `run <domain> [--db <name>]` - Trigger an immediate database backup49+- `run <app> --service <service> --database <database>` - Run one declared
17-- `detect <domain>` - Detect supported databases for a domain50+ database backup now
18-- `status` - Show database backup status51+- `status` - Show stored backups plus declared targets that have no completed
19-52+ backup yet
20-Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
21 53
22-## gordon backups volumes54+## gordon backups volume
23 55
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.
25 58
26 ```bash59 ```bash
27-gordon backups volumes <subcommand>60+gordon backups volume <subcommand>
28 ```61 ```
29 62
30 Subcommands:63 Subcommands:
31 64
32-- `list [domain]` - List completed volume backup archives65+- `list [app]` - List completed volume backup archives
33-- `run [domain] [--volume <name>]` - Trigger volume backups now66+- `run <app> --service <service> --volume <volume>` - Run one declared volume
67+ backup now
34 - `status` - Show completed archives plus current/recent in-memory job state68 - `status` - Show completed archives plus current/recent in-memory job state
35 69
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.
37 83
38 ## Examples84 ## Examples
39 85
40 ```bash86 ```bash
41 # Database backups87 # Database backups
42-gordon backups databases list88+gordon backups list
43-gordon backups databases run app.example.com --db postgres89+gordon backups list shop
44-gordon backups databases detect app.example.com90+gordon backups run shop --service api --database orders
45-gordon backups databases status91+gordon backups status
46 92
47 # Volume backups93 # Volume backups
48-gordon backups volumes list94+gordon backups volume list
49-gordon backups volumes list app.example.com95+gordon backups volume list shop
50-gordon backups volumes run96+gordon backups volume run shop --service api --volume data
51-gordon backups volumes run app.example.com --volume gordon-app-example-com-data97+gordon backups volume status
52-gordon backups volumes status
53 ```98 ```
54 99
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+
55 ## JSON Output107 ## JSON Output
56 108
57-Database and volume list commands support `--json`.109+Every list, run, and status command supports `--json`.
58 110
59 ```bash111 ```bash
60-gordon backups volumes list --json112+gordon backups volume list --json
61 ```113 ```
62 114
63 ## Required Permissions115 ## Required Permissions
64 116
65-- Read operations (`list`, `status`, `detect`) require `admin:status:read`.117+- Read operations (`list`, `status`) require `admin:status:read`.
66 - `run` requires `admin:config:write`.118 - `run` requires `admin:config:write`.
67 119
68 ## Related120 ## Related
69 121
70 - [CLI Commands](./index.md)122 - [CLI Commands](./index.md)
71 - [Backups Configuration](../config/backups.md)123 - [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 @@
1 # Images Command1 # Images Command
2 2
3-List and prune runtime/registry images.3+Push, list, and prune runtime/registry images.
4 4
5 ## gordon images5 ## gordon images
6 6
@@ -10,12 +10,126 @@ gordon images <subcommand>
10 10
11 Subcommands:11 Subcommands:
12 12
13+- `push` - Tag and push an image to the Gordon registry (never deploys).
13 - `list` - List runtime images and registry tags.14 - `list` - List runtime images and registry tags.
14 - `prune` - Prune dangling runtime images and old registry tags.15 - `prune` - Prune dangling runtime images and old registry tags.
15 - `tags` - List registry tags for a specific repository.16 - `tags` - List registry tags for a specific repository.
16 17
17 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).18 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
18 19
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+
19 ## gordon images list133 ## gordon images list
20 134
21 ```bash135 ```bash
@@ -59,11 +173,13 @@ gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry]
59 173
60 By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations.174 By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations.
61 175
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+
62 Flags:178 Flags:
63 179
64 | Flag | Default | Description |180 | Flag | Default | Description |
65 |------|---------|-------------|181 |------|---------|-------------|
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 |
67 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |183 | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) |
68 | `--dangling` | `false` | Restrict scope to dangling runtime images only |184 | `--dangling` | `false` | Restrict scope to dangling runtime images only |
69 | `--registry` | `false` | Restrict scope to registry tag retention only |185 | `--registry` | `false` | Restrict scope to registry tag retention only |
@@ -80,7 +196,22 @@ Flags:
80 196
81 - `latest` is always preserved when present.197 - `latest` is always preserved when present.
82 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.198 - `--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.
84 215
85 ## gordon images tags216 ## gordon images tags
86 217
@@ -128,7 +259,8 @@ gordon images prune --dangling
128 # Prune registry tags only, keeping latest + 5 previous259 # Prune registry tags only, keeping latest + 5 previous
129 gordon images prune --registry --keep-releases 5260 gordon images prune --registry --keep-releases 5
130 261
131-# Preview cleanup without applying262+# Inspect what prune would remove without applying
263+# (also lists every skipped candidate with its reason)
132 gordon images prune --dry-run264 gordon images prune --dry-run
133 265
134 # Skip confirmation prompt266 # Skip confirmation prompt
M docs/cli/index.md +56 -124
@@ -1,8 +1,8 @@
1 # CLI Commands1 # CLI Commands
2 2
3-Gordon provides a command-line interface for server management, deployment, and authentication.3+Gordon provides a command-line interface for server management, app deployment, and authentication.
4 4
5-Most `list` commands also support `--json` for machine-readable output.5+Most `list`/`show` commands also support `--json` for machine-readable output.
6 6
7 Commands are organized by where they run:7 Commands are organized by where they run:
8 8
@@ -20,30 +20,15 @@ Commands are organized by where they run:
20 20
21 ## Management Commands (local or remote)21 ## Management Commands (local or remote)
22 22
23-Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.23+Management commands use the authenticated daemon API. By default they connect through the owner-only local Unix socket; add `--remote` to use authenticated HTTP/TLS against another Gordon instance.
24 24
25 | Command | Description | Documentation |25 | Command | Description | Documentation |
26 |---------|-------------|---------------|26 |---------|-------------|---------------|
27-| `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |27+| `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
28-| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |28+| `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
29-| `gordon backups` | Manage database backups | [backup](./backup.md) |29+| `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) |
30-| `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |30+| `gordon images` | Push, list, and prune images | [images](./images.md) |
31-| `gordon config show` | Show server configuration | [config](./config.md) |31+| `gordon volumes` | Manage volumes | [volumes](./volumes.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 | - |
47 32
48 ## Client Commands33 ## Client Commands
49 34
@@ -60,54 +45,59 @@ Management commands run locally through in-process services by default. Add `--r
60 gordon serve45 gordon serve
61 gordon serve --config /path/to/config.toml46 gordon serve --config /path/to/config.toml
62 47
63-# Reload configuration48+# Reload installation configuration (never activates app state)
64-gordon reload49+gordon daemon reload
65-50+
66-# Deploy a specific route51+# Applications (daemon-owned; fail without a reachable daemon)
67-gordon deploy myapp.example.com52+gordon apps apply --file blog.toml
68-53+gordon apps apply --file blog.toml --deploy
69-# Restart a running container54+gordon apps list
70-gordon restart myapp.example.com55+gordon apps show blog
71-56+gordon apps diff blog
72-# First-time route setup57+gordon apps deploy blog
73-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production58+gordon apps restart blog
74-59+gordon apps stop blog
75-# Then push and deploy60+gordon apps start blog
76-gordon push myapp:latest --domain app.example.com --build --no-confirm61+gordon apps remove blog
77-62+gordon apps operations show blog --key <operation-key>
78-# Push an image and deploy63+gordon apps operations watch blog --key <operation-key>
79-gordon push myapp --build64+gordon apps secrets list blog
80-65+
81-# Push and deploy without confirmation66+# Daemon
82-gordon push myapp --no-confirm67+gordon daemon status
83-68+gordon daemon config show
84-# Pin to a specific tag69+gordon daemon config validate --file /path/to/gordon.toml
85-gordon pin myapp.example.com70+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
86 75
87 # View logs76 # View logs
88-gordon logs # Gordon process logs77+gordon daemon logs # Gordon process logs
89-gordon logs -f # Follow process logs78+gordon daemon logs -f # Follow process logs
90-gordon logs -n 100 # Last 100 lines79+gordon daemon logs -n 100 # Last 100 process-log lines
91-gordon logs myapp.example.com # Container logs for myapp.example.com80+gordon apps logs blog --service web # App service logs
92-gordon logs myapp.example.com -f # Follow container logs81+gordon apps logs blog --service web --follow # Follow app service logs
93 82
94 # Check version83 # Check version
95 gordon version84 gordon version
96 85
97 # Traffic plane86 # Traffic plane
98-gordon traffic status --remote prod87+gordon daemon traffic --remote prod
99-gordon traffic status --remote prod --json88+gordon daemon traffic --remote prod --json
100 89
101 # Backups90 # Backups
102 gordon backups list91 gordon backups list
103-gordon backups run app.example.com92+gordon backups run shop --service api --database orders
104-gordon backups detect app.example.com93+gordon backups volume run shop --service api --volume data
105 gordon backups status94 gordon backups status
106 95
107 # Images96 # Images
97+gordon images push myapp --build --remote prod
108 gordon images list98 gordon images list
109-gordon images prune --runtime-only99+gordon images prune --dry-run
110-gordon images prune --keep 3100+gordon images prune --keep-releases 3
111 101
112 # Authentication102 # Authentication
113 gordon auth login --remote https://gordon.example.com --token $TOKEN103 gordon auth login --remote https://gordon.example.com --token $TOKEN
@@ -119,43 +109,14 @@ gordon auth token list
119 gordon auth token revoke <token-id>109 gordon auth token revoke <token-id>
120 gordon auth internal110 gordon auth internal
121 111
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-
139 # Remotes112 # Remotes
140 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN113 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
141 gordon remotes list114 gordon remotes list
142 gordon remotes use prod115 gordon remotes use prod
143 116
144-# Preview environments
145-gordon preview create app.example.com --branch feature-x
146-gordon preview list
147-gordon preview extend app.example.com --branch feature-x
148-gordon preview delete app.example.com --branch feature-x
149-
150 # Volumes117 # Volumes
151 gordon volumes list118 gordon volumes list
152 gordon volumes prune119 gordon volumes prune
153-
154-# Auto-route allowlist
155-gordon autoroute allow list
156-gordon autoroute allow add example.com
157-gordon autoroute allow add "*.staging.example.com"
158-gordon autoroute allow remove example.com
159 ```120 ```
160 121
161 ## Global Options122 ## Global Options
@@ -173,64 +134,35 @@ The CLI can target remote Gordon instances using client config, an active remote
173 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides134 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
174 when you want to bypass your saved configuration.135 when you want to bypass your saved configuration.
175 136
176-When no explicit remote is selected and no active remote is configured, Gordon can now137+When no explicit remote is selected and no active remote is configured, Gordon can
177-**auto-infer a saved remote** for commands that already have a concrete target. It probes your138+**auto-infer a saved remote** for `gordon images push`. It probes your saved remotes and uses the
178-saved remotes and uses the remote automatically when exactly one matches. If multiple remotes139+remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
179-match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe140+an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
180-fails, Gordon also stops rather than guessing.141+stops rather than guessing.
181-
182-Auto-inference currently applies to target-based commands such as:
183-- `gordon push`
184-- `gordon attachments push`
185-- `gordon deploy <domain>`
186-- `gordon restart <domain>`
187-- `gordon pin <domain>` / `gordon pin list <domain>`
188-- `gordon routes show <domain>` / `gordon routes remove <domain>`
189-- `gordon secrets list|set|remove <domain>`
190-- `gordon attachments list <target>` / `gordon attachments add|remove <target> ...`
191-- `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>`
192-- `gordon images tags <repository>`
193-- `gordon logs <domain>`
194-
195-`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
196-nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
197-one to force a single target.
198 142
199 ```bash143 ```bash
200-# Aggregate routes views
201-gordon routes list
202-gordon routes status
203-
204-# Single-target override
205-gordon routes list --remote prod
206-GORDON_REMOTE=prod gordon routes status
207-
208 # Auto-inferred single match from saved remotes144 # Auto-inferred single match from saved remotes
209-gordon push myapp --build145+gordon images push myapp --build
210-gordon deploy app.example.com
211 ```146 ```
212 147
213 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.148 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
214-If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
215 149
216 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.150 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
217 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`151 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`
218 or in a specific entry in `~/.config/gordon/remotes.toml`.152 or in a specific entry in `~/.config/gordon/remotes.toml`.
219-For Tailscale setups, you can also avoid `--insecure` by using the machine `*.ts.net` name with Tailscale-issued TLS certs in Gordon server config.
220-Your public app domains can still use wildcard DNS and normal reverse-proxy routing.
221 153
222 ```bash154 ```bash
223 # Using flags (use the gordon_domain from remote Gordon config)155 # Using flags (use the gordon_domain from remote Gordon config)
224-gordon routes list --remote https://gordon.example.com --token $TOKEN156+gordon daemon status --remote https://gordon.example.com --token $TOKEN
225 157
226 # Against self-signed/private CA endpoint158 # Against self-signed/private CA endpoint
227-gordon --remote https://gordon.example.com --token $TOKEN --insecure status159+gordon --remote https://gordon.example.com --token $TOKEN --insecure daemon status
228 160
229 # Using environment variables161 # Using environment variables
230 export GORDON_REMOTE=https://gordon.example.com162 export GORDON_REMOTE=https://gordon.example.com
231 export GORDON_TOKEN=$TOKEN163 export GORDON_TOKEN=$TOKEN
232 export GORDON_INSECURE=true164 export GORDON_INSECURE=true
233-gordon routes list165+gordon daemon status
234 ```166 ```
235 167
236 ## Exit Codes168 ## 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>
163 ### Description163 ### Description
164 164
165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166-`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
167 166
168-If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for commands with a concrete target (for example `gordon push myapp`, `gordon deploy app.example.com`, or `gordon images tags myapp`). It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.167+If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon 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`.
169 168
170 ```bash169 ```bash
171 gordon remotes use prod170 gordon remotes use prod
172-gordon secrets list app.com # Uses prod remote automatically171+gordon apps list # Uses prod remote automatically
173-gordon routes list # Aggregates local + saved remotes172+gordon daemon status # Uses prod remote automatically
174-gordon routes status # Aggregates local + saved remotes
175 ```173 ```
176 174
177 ### Example175 ### Example
@@ -246,7 +244,7 @@ token = "eyJ..."
246 244
247 When multiple sources specify remote or token, the CLI uses this priority:245 When multiple sources specify remote or token, the CLI uses this priority:
248 246
249-For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.247+For aggregate views, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
250 Auto-inference is not used for those aggregate views.248 Auto-inference is not used for those aggregate views.
251 249
252 **Remote target selection:**250 **Remote target selection:**
@@ -271,13 +269,8 @@ Auto-inference is not used for those aggregate views.
271 This allows overriding specific values while keeping defaults:269 This allows overriding specific values while keeping defaults:
272 270
273 ```bash271 ```bash
274-# Aggregate routes views
275-gordon routes list
276-gordon routes status
277-
278 # Force one target272 # Force one target
279-GORDON_REMOTE=prod gordon routes list273+GORDON_REMOTE=prod gordon daemon status
280-GORDON_REMOTE=prod gordon routes status
281 ```274 ```
282 275
283 ---276 ---
@@ -294,12 +287,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
294 287
295 # Work with prod288 # Work with prod
296 gordon remotes use prod289 gordon remotes use prod
297-gordon secrets list myapp.example.com290+gordon apps list
298-gordon routes list --remote prod291+gordon daemon status
299 292
300 # Switch to staging293 # Switch to staging
301 gordon remotes use staging294 gordon remotes use staging
302-GORDON_REMOTE=staging gordon routes status295+GORDON_REMOTE=staging gordon daemon status
303 ```296 ```
304 297
305 ### CI/CD Pipeline298 ### CI/CD Pipeline
@@ -311,25 +304,21 @@ env:
311 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}304 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
312 305
313 steps:306 steps:
314- - name: Deploy to Gordon307+ - name: Push to Gordon
315 run: |308 run: |
316- gordon routes deploy myapp.example.com309+ gordon images push myapp --build --remote ${{ secrets.GORDON_URL }}
317 ```310 ```
318 311
319 ### Compare Environments312 ### Compare Environments
320 313
321 ```bash314 ```bash
322-# Aggregate route inventory/status across local + saved remotes
323-gordon routes list
324-gordon routes status
325-
326 # Compare one target at a time315 # Compare one target at a time
327-gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN316+gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN
328-GORDON_REMOTE=staging gordon routes status317+GORDON_REMOTE=staging gordon daemon status
329 318
330 # Or switch between active remotes for other commands319 # Or switch between active remotes for other commands
331-gordon remotes use prod && gordon secrets list myapp.example.com320+gordon remotes use prod && gordon apps list
332-gordon remotes use staging && gordon secrets list myapp.example.com321+gordon remotes use staging && gordon apps list
333 ```322 ```
334 323
335 ### Private Admin + Wildcard App Domains324 ### 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
354 343
355 If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA.344 If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA.
356 345
357-`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`.346+`insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: the reverse proxy can still serve wildcard app domains like `*.example.com`.
358 347
359 ## Migration from [client] Config348 ## Migration from [client] Config
360 349
D docs/cli/restart.md +0 -54
@@ -1,54 +0,0 @@
1-# Restart Command
2-
3-Restart a running container for a route.
4-
5-## gordon restart
6-
7-### Synopsis
8-
9-```bash
10-gordon restart <domain> [options]
11-```
12-
13-### Arguments
14-
15-| Argument | Description |
16-|----------|-------------|
17-| `<domain>` | The route domain to restart |
18-
19-### Options
20-
21-| Option | Description |
22-|--------|-------------|
23-| `--with-attachments` | Also restart attached services (databases, caches) |
24-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
25-| `--token` | Authentication token for remote |
26-
27-### Description
28-
29-Restarts the container for the specified route. This is useful after updating
30-environment variables or secrets without performing a full redeploy.
31-
32-When `--with-attachments` is set, Gordon also restarts any attachment containers
33-for the route.
34-
35-### Examples
36-
37-```bash
38-# Restart main container only
39-gordon restart myapp.example.com
40-
41-# Restart with attachments
42-gordon restart myapp.example.com --with-attachments
43-```
44-
45-### Notes
46-
47-- Local mode works when run on the Gordon host. It uses the same deploy-signal path as `gordon deploy <domain>`.
48-- `--with-attachments` is only supported in remote mode.
49-- In remote mode, target your Gordon admin endpoint (for example `https://gordon.example.com`), not the registry host (for example `https://reg.example.com`).
50-
51-## Related
52-
53-- [CLI Overview](./index.md)
54-- [Secrets Command](./secrets.md)
D docs/cli/routes.md +0 -442
@@ -1,442 +0,0 @@
1-# Routes Commands
2-
3-Manage routes on local or remote Gordon instances.
4-
5-Most remote-capable commands use client config or an active remote by default.
6-`gordon routes list` and `gordon routes status` are different: when neither
7-`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8-saved remote under its own heading. See [CLI Overview](./index.md).
9-
10-## gordon routes
11-
12-### Subcommands
13-
14-| Subcommand | Description |
15-|------------|-------------|
16-| `list` | List routes by domain and image |
17-| `status` | Show detailed route status |
18-| `show` | Show details for a single route |
19-| `add` | Create or update a route |
20-| `remove` | Remove a route |
21-| `deploy` | Deploy a specific route |
22-
23----
24-
25-## gordon routes list
26-
27-List routes by domain and image only.
28-
29-When no target is selected, Gordon shows local routes first, then each saved
30-remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31-target only.
32-
33-```bash
34-gordon routes list
35-gordon routes list --json
36-gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37-GORDON_REMOTE=prod gordon routes list
38-```
39-
40-### Output
41-
42-```text
43-Routes
44-
45-Local
46- app.example.com myapp:latest
47- api.example.com myapi:v2.1.0
48-
49-Remote: hetzner-vps
50- gordon.example.com gordon-webapp:latest
51-
52-Remote: igor
53- grafana.supri.xyz grafana
54- test.supri.xyz hello-test
55-```
56-
57-### JSON Output
58-
59-```bash
60-gordon routes list --json
61-```
62-
63-```json
64-[
65- {
66- "kind": "local",
67- "name": "local",
68- "routes": [
69- {
70- "domain": "app.example.com",
71- "image": "myapp:latest"
72- }
73- ]
74- },
75- {
76- "kind": "remote",
77- "name": "igor",
78- "url": "https://gordon.supri.xyz",
79- "routes": [
80- {
81- "domain": "grafana.supri.xyz",
82- "image": "grafana"
83- }
84- ]
85- }
86-]
87-```
88-
89-Single-target mode still returns a one-element array. Sections can also include
90-an `error` field when a target is unavailable.
91-
92-### Options
93-
94-| Option | Description |
95-|--------|-------------|
96-| `--json` | Output routes as JSON |
97-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98-| `--token` | Authentication token for remote |
99-
100----
101-
102-## gordon routes status
103-
104-Show detailed route status for each target.
105-
106-`routes status` uses the same target selection rules as `routes list`. When no
107-target is selected, Gordon shows local status first, then each saved remote
108-under its own heading.
109-
110-```bash
111-gordon routes status
112-gordon routes status --json
113-gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114-GORDON_REMOTE=prod gordon routes status
115-```
116-
117-### Output
118-
119-```text
120-Route Status
121-
122-Local
123- <rich tree for local routes>
124-
125-Remote: hetzner-vps
126- <rich tree for that remote>
127-
128-Remote: igor
129- <rich tree for that remote>
130-```
131-
132-The rich view keeps network grouping, container status, HTTP probe status, and
133-attachments within each target.
134-
135-### JSON Output
136-
137-```json
138-[
139- {
140- "kind": "local",
141- "name": "local",
142- "routes": [
143- {
144- "domain": "app.example.com",
145- "image": "myapp:latest",
146- "container_id": "abcd1234",
147- "container_status": "running",
148- "http_status": 200,
149- "network": "gordon-shared",
150- "attachments": [
151- {
152- "name": "postgres",
153- "image": "postgres:18",
154- "status": "running"
155- }
156- ]
157- }
158- ]
159- }
160-]
161-```
162-
163-Single-target mode still returns a one-element array. Sections can also include
164-an `error` field when a target is unavailable.
165-
166-### Options
167-
168-| Option | Description |
169-|--------|-------------|
170-| `--json` | Output routes as JSON |
171-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
172-| `--token` | Authentication token for remote |
173-
174----
175-
176-## gordon routes add
177-
178-Create a new route or update an existing route.
179-
180-```bash
181-gordon routes add <domain> <image>
182-gordon routes add myapp.example.com myapp:latest
183-```
184-
185-If the route already exists, Gordon updates it to the new image instead of failing.
186-The image does not need to be pushed to the Gordon registry before you add the route.
187-
188-### Arguments
189-
190-| Argument | Description |
191-|----------|-------------|
192-| `<domain>` | The domain name for the route |
193-| `<image>` | The container image to deploy |
194-
195-### Options
196-
197-| Option | Description |
198-|--------|-------------|
199-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
200-| `--token` | Authentication token for remote |
201-
202-### Examples
203-
204-```bash
205-# Local
206-gordon routes add myapp.example.com myapp:latest
207-gordon routes add api.example.com myapi:v2.1.0
208-
209-# Update an existing route
210-gordon routes add myapp.example.com myapp:v2
211-
212-# Remote (override)
213-gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
214-```
215-
216-### Notes
217-
218-- `gordon routes add` is idempotent: it creates the route when missing and updates it when present.
219-- You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image.
220-
221----
222-
223-## gordon routes show
224-
225-Show detailed information about a single route.
226-
227-```bash
228-gordon routes show <domain>
229-gordon routes show myapp.example.com
230-gordon routes show myapp.example.com --json
231-```
232-
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `<domain>` | The domain name of the route to inspect |
238-
239-### Options
240-
241-| Option | Description |
242-|--------|-------------|
243-| `--json` | Output route details as JSON |
244-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
245-| `--token` | Authentication token for remote |
246-
247-### Description
248-
249-Displays the configured image for the route plus any available container and HTTP health information.
250-In local-only mode, health data may be unavailable.
251-
252-### Examples
253-
254-```bash
255-# Local
256-gordon routes show myapp.example.com
257-
258-# JSON
259-gordon routes show myapp.example.com --json
260-
261-# Remote (override)
262-gordon routes show myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
263-```
264-
265-### JSON Output
266-
267-```json
268-{
269- "domain": "myapp.example.com",
270- "image": "myapp:latest",
271- "container_status": "running",
272- "http_status": 200
273-}
274-```
275-
276----
277-
278-## gordon routes diagnose
279-
280-Diagnose route configuration, runtime state, preserved volumes, and orphaned attachment containers.
281-
282-```bash
283-gordon routes diagnose <domain>
284-gordon routes diagnose myapp.example.com --json
285-```
286-
287-Use this after route deletion or failed deployment to see whether runtime state still exists and which safe cleanup commands to run next.
288-
289-### Output
290-
291-```text
292-Route diagnosis: myapp.example.com
293-Image: myapp:latest
294-Container: running abc123def456
295-Preserved volume: gordon-myapp-example-com-data
296-Orphaned attachment: postgres
297-Hint: persistent volumes are preserved by default
298-Hint: run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes
299-```
300-
301-### JSON Output
302-
303-```json
304-{
305- "domain": "myapp.example.com",
306- "configured": true,
307- "route": {"domain": "myapp.example.com", "image": "myapp:latest", "https": true},
308- "runtime": {"domain": "myapp.example.com", "container_id": "abc123def456", "container_status": "running"},
309- "volumes": [{"name": "gordon-myapp-example-com-data", "in_use": false}],
310- "orphaned_attachments": [{"container_id": "pg123", "name": "postgres", "image": "postgres:16", "status": "running", "owner": "myapp.example.com"}],
311- "hints": ["persistent volumes are preserved by default"]
312-}
313-```
314-
315-### Next Steps
316-
317-```bash
318-gordon routes purge myapp.example.com # dry-run retained resources
319-gordon routes purge myapp.example.com --attachments --force
320-```
321-
322----
323-
324-## gordon routes purge
325-
326-Review retained resources for a route and execute supported explicit cleanup actions only when forced.
327-
328-```bash
329-gordon routes purge myapp.example.com # dry-run
330-gordon routes purge myapp.example.com --attachments --force
331-gordon routes purge myapp.example.com --volumes # report preserved volumes for manual review
332-```
333-
334-### Dry-run Output
335-
336-```text
337-Purge dry-run: myapp.example.com
338-Preserved attachments:
339- postgres (running)
340-Purge candidate: route_container abc123def456
341-Preserved volume: gordon-myapp-example-com-data
342-Hint: dry-run only; add --force with explicit category flags to execute supported purge actions
343-```
344-
345-### Forced Attachment Cleanup Output
346-
347-```text
348-Purge completed: myapp.example.com
349-Removed containers:
350- postgres
351-Hint: attachment volumes and data were preserved
352-```
353-
354-Purge is intentionally conservative. Volumes are reported and preserved unless a dedicated volume deletion flow supports safe targeted deletion.
355-
356----
357-
358-## gordon routes remove
359-
360-Remove a route.
361-
362-Route removal is safe by default. Gordon removes the route from configuration and reconciles the active route container so it is no longer served or restarted by Gordon's monitor. Stateful data is preserved. Volumes and attachment data are not deleted unless a separate explicit purge flow requests destructive cleanup.
363-
364-```bash
365-gordon routes remove <domain>
366-gordon routes remove myapp.example.com
367-```
368-
369-### Arguments
370-
371-| Argument | Description |
372-|----------|-------------|
373-| `<domain>` | The domain name of the route to remove |
374-
375-### Options
376-
377-| Option | Description |
378-|--------|-------------|
379-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
380-| `--token` | Authentication token for remote |
381-
382-### Examples
383-
384-```bash
385-# Local
386-gordon routes remove myapp.example.com
387-
388-# Remote (override)
389-gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
390-
391-# Typical output
392-Route removed: myapp.example.com
393-
394-Removed containers:
395- gordon-myapp.example.com (abc123def456)
396-
397-Preserved attachments:
398- postgres (running)
399-```
400-
401----
402-
403-## gordon routes deploy
404-
405-Deploy or redeploy a specific route.
406-
407-```bash
408-gordon routes deploy <domain>
409-gordon routes deploy myapp.example.com
410-```
411-
412-### Arguments
413-
414-| Argument | Description |
415-|----------|-------------|
416-| `<domain>` | The domain name of the route to deploy |
417-
418-### Options
419-
420-| Option | Description |
421-|--------|-------------|
422-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
423-| `--token` | Authentication token for remote |
424-
425-### Description
426-
427-Triggers a fresh image pull and container redeployment for the specified route.
428-
429-### Examples
430-
431-```bash
432-# Local
433-gordon routes deploy myapp.example.com
434-
435-# Remote (override)
436-gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
437-```
438-
439-## Related
440-
441-- [CLI Overview](./index.md)
442-- [Routes Configuration](../config/routes.md)
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]
23 Starts the Gordon server, which includes:23 Starts the Gordon server, which includes:
24 24
25 - **Container Registry** - Receives image pushes on the registry port25 - **Container Registry** - Receives image pushes on the registry port
26-- **HTTP Proxy** - Routes traffic to containers on the proxy port26+- **Reverse Proxy** - Routes app hosts to recorded loopback backends
27-- **Event Bus** - Coordinates deployments and updates27+- **Event Bus** - Coordinates runtime notifications
28 - **Config Watcher** - Monitors configuration file for changes28 - **Config Watcher** - Monitors configuration file for changes
29 29
30 ### Configuration File Detection30 ### Configuration File Detection
@@ -68,8 +68,8 @@ Gordon responds to these signals:
68 |--------|--------|68 |--------|--------|
69 | `SIGTERM` | Graceful shutdown |69 | `SIGTERM` | Graceful shutdown |
70 | `SIGINT` | Graceful shutdown (Ctrl+C) |70 | `SIGINT` | Graceful shutdown (Ctrl+C) |
71-| `SIGUSR1` | Reload configuration |71+| `SIGUSR1` | Reload installation configuration |
72-| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |72+| `SIGUSR2` | Reserved; no app deployment action |
73 73
74 ### Running with systemd74 ### Running with systemd
75 75
@@ -106,21 +106,16 @@ sudo loginctl enable-linger $USER
106 7. Register event handlers106 7. Register event handlers
107 8. Start config file watcher107 8. Start config file watcher
108 9. Start HTTP servers108 9. Start HTTP servers
109-10. Run best-effort startup recovery (sync existing containers, then recover configured routes)109+10. Run best-effort startup recovery (reconcile apps intended to run from active state)
110 110
111 ### Startup Recovery111 ### Startup Recovery
112 112
113 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.113 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.
114 114
115-1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.115+1. **Reconcile app boot state** - Gordon ensures apps intended to run are running from active state (stopped-intent apps stay stopped, pending desired revisions are never activated).
116-2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.116+4. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
117-3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
118 117
119-This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.118+Reload and startup recovery never activate pending desired app state, re-resolve images, or flip intent. They touch installation settings and runtime reconciliation only.
120-
121-Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes.
122-
123-Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
124 119
125 ### Shutdown Sequence120 ### Shutdown Sequence
126 121
@@ -133,164 +128,6 @@ Startup recovery is intentionally narrower than a manual deploy. It only starts
133 128
134 ---129 ---
135 130
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-
294 ## gordon version131 ## gordon version
295 132
296 Print version information.133 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
19 19
20 ### `gordon volumes prune`20 ### `gordon volumes prune`
21 21
22-Remove orphaned volumes no longer associated with a running container.22+Remove app-owned volumes that were explicitly released and are no longer used by any container.
23 23
24 ```bash24 ```bash
25 gordon volumes prune25 gordon volumes prune
@@ -29,10 +29,22 @@ gordon volumes prune --no-confirm
29 29
30 | Flag | Description |30 | Flag | Description |
31 |------|-------------|31 |------|-------------|
32-| `--dry-run` | Show what would be removed without deleting |32+| `--dry-run` | Report the plan without deleting |
33 | `--no-confirm` | Skip confirmation prompt |33 | `--no-confirm` | Skip confirmation prompt |
34 | `--json` | Output as JSON |34 | `--json` | Output as JSON |
35 35
36+#### What survives
37+
38+A volume is removed only when all of the following hold:
39+
40+- A durable ownership record explicitly marks it `released`.
41+- The runtime labels agree with that record: the app, the app incarnation UUID, and the service.
42+- No container mounts it.
43+
44+Everything else survives. Attached and retained volumes, volumes carrying only `gordon.managed`, volumes with no Gordon provenance, and volumes whose ownership cannot be established all survive. Ownership is never inferred from the volume name, and labels alone never make a volume deletable.
45+
46+With current metadata no lifecycle marks a volume `released`, so a volume prune normally reports a zero-deletion success. The command still lists every candidate with its verdict and reason, and `--json` returns the same plan shape as `gordon images prune`.
47+
36 ## Related48 ## Related
37 49
38 - [Volumes Configuration](../config/volumes.md)50 - [Volumes Configuration](../config/volumes.md)
M docs/concepts.md +101 -184
@@ -9,276 +9,193 @@ Your development machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2
9 Gordon flips the typical deployment model:9 Gordon flips the typical deployment model:
10 10
11 1. **Build locally** where you have computing power11 1. **Build locally** where you have computing power
12-2. **Push the finished image** to your VPS12+2. **Push the finished image** to your VPS registry
13-3. **Gordon deploys automatically**13+3. **Apply the app manifest** declaring the image
14+4. **Deploy** to activate it
14 15
15 This means faster builds, less VPS resource usage, and a simpler deployment workflow.16 This means faster builds, less VPS resource usage, and a simpler deployment workflow.
16 17
17-## Push-to-Deploy18+## Push, Apply, Deploy
18 19
19-Gordon combines a Docker registry with automatic deployment:20+Gordon combines a Docker registry with a declarative app runtime:
20 21
21 ```22 ```
22 ┌──────────────┐ push ┌──────────────┐23 ┌──────────────┐ push ┌──────────────┐
23 │ docker build │ ──────────────>│ Gordon │24 │ docker build │ ──────────────>│ Gordon │
24-│ docker push │ │ Registry │25+│ docker push │ (OCI only, │ Registry │
25-└──────────────┘ └──────┬───────┘26+└──────────────┘ never deploys)└──────┬───────┘
26 │27 │
27- │ event: image.pushed28+ ┌───────────────────┴───────────────────┐
29+ │ gordon apps apply --file blog.toml │ desired state
30+ │ gordon apps deploy blog │ activation
31+ └───────────────────┬───────────────────┘
28 v32 v
29 ┌──────────────┐33 ┌──────────────┐
30- │ Deploy │34+ │ App │
31- │ Container │35+ │ Containers │
32 └──────────────┘36 └──────────────┘
33 ```37 ```
34 38
35-When you push an image, Gordon:39+Pushing an image only stores it. Nothing runs, no route is created, no manifest is modified. Activation is always an explicit `gordon apps deploy`.
36 40
37-1. Stores the image in its registry41+## Declarative Apps
38-2. Fires an `image.pushed` event
39-3. Looks up the route for that image
40-4. Deploys a new container
41-5. Updates the proxy routing
42-6. Stops the old container
43 42
44-## Zero-Downtime Updates43+One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities.
45 44
46-Gordon ensures your app stays available during updates:45+```toml
47-46+name = "blog"
48-1. **New container starts** while old container is still running
49-2. **Health check** waits for new container to be ready
50-3. **Traffic switches** to the new container
51-4. **Old container stops** after traffic has moved
52-
53-```
54-Time ─────────────────────────────────────────────>
55-
56-Old Container: [═══════════════════]
57- ↓ stop
58-New Container: [═════════════════════════>
59- ↑ start ↑ traffic routed
60-```
61-
62-## Deletion and Cleanup Lifecycle
63 47
64-Gordon separates configuration removal from destructive data cleanup:48+[env]
49+APP_ENV = "production" # app-wide public env, injected into all services
65 50
66-- **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`.51+[services.web]
67-- **active** — an entity has runtime state, such as a running Gordon-managed container.52+image = "gordon.mydomain.com/blog:1.4.2"
68-- **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data.
69-- **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis.
70-- **purged** — state explicitly deleted by a destructive cleanup command.
71-- **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later.
72 53
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
74 57
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+```
76 61
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.
78 68
79-- Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`.69+## Updates
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.
83 70
84-## Routes71+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.
85 72
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.
87 74
88-```toml75+```
89-[routes]76+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
90-"app.example.com" = { image = "myapp:latest" }
91-"api.example.com" = { image = "myapi:v2.1.0" }
92 ```77 ```
93 78
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.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.
95-
96-When a request comes in for `app.example.com`, Gordon:
97 80
98-1. Looks up the route configuration81+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.
99-2. Finds the running container for `myapp:latest`
100-3. Proxies the request to that container
101 82
102-### Route Domains83+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.
103 84
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`.
105 86
106-```toml87+`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.
107-[routes]
108-"dev-app.example.com" = { image = "internal-app:latest", https = false }
109-```
110 88
111-## Network Isolation89+## Deletion and Cleanup Lifecycle
112-
113-Each app runs in its own isolated Docker network:
114 90
115-```91+Gordon separates workload removal from destructive data cleanup:
116-┌────────────────────────────────────────────────┐
117-│ gordon-app-mydomain-com │
118-│ │
119-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
120-│ │ App │───>│ Postgres │ │ Redis │ │
121-│ │ :3000 │ │ :5432 │ │ :6379 │ │
122-│ └──────────┘ └──────────┘ └──────────┘ │
123-│ │
124-└────────────────────────────────────────────────┘
125-```
126 92
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.
128 97
129-- Containers can't access each other's services98+Safe removal is the default. `gordon apps remove` withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no `purge`: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes.
130-- Services are only accessible by name within their network
131-- No port conflicts between apps
132 99
133-## Attachments100+## App HTTP Hosts
134 101
135-Attachments are service dependencies for your apps:102+An app's HTTP interfaces declare the hosts Gordon serves:
136 103
137 ```toml104 ```toml
138-[attachments]105+[[services.web.http]]
139-"app.mydomain.com" = ["postgres:latest", "redis:latest"]106+host = "app.example.com"
107+port = 3000
140 ```108 ```
141 109
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:
143 111
144-```javascript112+1. Looks up the host in the ACTIVE projection (merged with installation external routes)
145-// In your app113+2. Finds the recorded loopback backend for that host (never a container IP — rootless-first)
146-const db = await connect("postgresql://postgres:5432/mydb");114+3. Proxies the request to that backend
147-const cache = await connect("redis://redis:6379");
148-```
149 115
150-## Network Groups116+Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`).
151 117
152-Network groups allow multiple apps to share services:118+## Networks
153 119
154-```toml120+Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership:
155-[network_groups]
156-"backend" = ["app.mydomain.com", "api.mydomain.com"]
157 121
158-[attachments]122+```toml
159-"backend" = ["shared-postgres:latest", "shared-redis:latest"]123+[[network.shared]]
124+network = "backend"
125+services = ["web", "worker"]
160 ```126 ```
161 127
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.
163 129
164 ## Volumes130 ## Volumes
165 131
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:
167 133
168-```dockerfile134+```toml
169-FROM postgres:18135+[[services.web.volume]]
170-VOLUME ["/var/lib/postgresql/data"]136+name = "web-data"
137+path = "/data"
171 ```138 ```
172 139
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.
174 141
175-- **auto_create**: Volumes are created automatically (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.
176-- **prefix**: Volume names are prefixed with `gordon-` (configurable)
177-- **preserve**: Volumes persist across container updates (default: true)
178 143
179-## Environment Variables144+## Environment and Secrets
180 145
181-Gordon loads environment variables from files based on the domain:146+App-wide public env is declared in the manifest:
182 147
148+```toml
149+[env]
150+APP_ENV = "production"
183 ```151 ```
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)
196 152
197-### Secret Providers153+Service-specific values use `secrets` even when non-confidential:
198 154
199-Environment files support secret provider syntax:155+```toml
200-156+[services.web.secrets]
201-```bash157+DATABASE_URL = "database-url"
202-# From Unix password manager (pass)
203-DATABASE_PASSWORD=${pass:myapp/db-password}
204-
205-# From SOPS encrypted files
206-API_SECRET=${sops:secrets.yaml:api.secret}
207 ```158 ```
208 159
209-## Configuration Hot-Reload160+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.
210-
211-Gordon watches its config file and reloads automatically:
212 161
213-1. Edit `~/.config/gordon/gordon.toml`162+## Installation Reload
214-2. Save the file
215-3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
216-4. Containers and proxy config sync to match the new configuration
217 163
218-Route file edits now reload automatically again.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.
219-
220-You can also trigger a reload:
221 165
222 ```bash166 ```bash
223-gordon reload167+gordon daemon reload
224 ```168 ```
225 169
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.
227 171
228 ## Event System172 ## Event System
229 173
230-Gordon uses an internal event system for coordination: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.
231-
232-| Event | Trigger | Action |
233-|-------|---------|--------|
234-| `image.pushed` | Image pushed to registry | Deploy container |
235-| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
236-| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
237-| `container.deployed` | Container started | Update proxy cache |
238 175
239 ## Backups and Recovery176 ## Backups and Recovery
240 177
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.
242 179
243-Current design:180+Stored backups are never deleted when declarations change — only schedules update on deploy.
244 181
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.
252 For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md).182 For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md).
253 183
254-## Container Labels184+## Container Identity
255 185
256-Gordon uses labels to track managed containers:186+Gordon stamps app ownership labels on every container and volume it creates:
257 187
258 | Label | Purpose |188 | Label | Purpose |
259 |-------|---------|189 |-------|---------|
260-| `gordon.managed=true` | Identifies Gordon-managed containers |190+| `gordon.managed=true` | Identifies Gordon-managed resources |
261-| `gordon.domain` | Domain this container serves |191+| `gordon.app` | App public name |
262-| `gordon.image` | Image name and tag |192+| `gordon.app.service` | Service name |
263-| `gordon.route` | Route this container handles |193+| `gordon.app.revision` | Active revision that created it |
264-| `gordon.attachment=true` | Container is an attachment service |
265-| `gordon.attached-to` | Which route this attachment serves |
266-
267-## Proxy Port Selection
268-
269-When a container exposes multiple ports, Gordon needs to know which one serves HTTP:
270-
271-```dockerfile
272-FROM gitea/gitea:latest
273-LABEL gordon.proxy.port=3000 # Route HTTP to port 3000
274-EXPOSE 22 # SSH
275-EXPOSE 3000 # HTTP
276-```
277 194
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.
279 196
280 ## Related197 ## Related
281 198
282 - [Configuration Reference](./config/index.md)199 - [Configuration Reference](./config/index.md)
283-- [Docker Labels Reference](./reference/docker-labels.md)200+- [Apps CLI](./cli/apps.md)
284-- [Environment Variables](./config/env.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):
87 | Scope | Permission |87 | Scope | Permission |
88 |-------|------------|88 |-------|------------|
89 | `admin:*:*` | Full admin access |89 | `admin:*:*` | Full admin access |
90-| `admin:routes:read` | Read-only routes access |90+| `admin:apps:read` | Read-only apps access (list, show, diff, status) |
91-| `admin:routes:write` | Routes write access |91+| `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) |
92 | `admin:config:read` | Read-only config access |92 | `admin:config:read` | Read-only config access |
93 | `admin:config:write` | Config write access |93 | `admin:config:write` | Config write access |
94 | `admin:status:read` | Read-only status/health |94 | `admin:status:read` | Read-only status/health |
95 | `admin:logs:read` | Read-only logs access |95 | `admin:logs:read` | Read-only logs access |
96 | `admin:volumes:read` | List Docker volumes |96 | `admin:volumes:read` | List Docker volumes |
97 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |97 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |
98-| `admin:secrets:read` | List secret keys |
99-| `admin:secrets:write` | Set/delete secrets |
100 98
101 Examples:99 Examples:
102 100
@@ -143,10 +141,19 @@ gordon auth token generate --subject temp --expiry 30d
143 141
144 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:142 Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
145 143
146-- `/admin/*` endpoints are not registered.144+- `/admin/*` endpoints are not registered on the TCP listener.
147 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).145 - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
148 - Remote registry and remote admin access are disabled.146 - Remote registry and remote admin access are disabled.
149 147
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+
150 Use this only when Gordon is intended for local machine usage.157 Use this only when Gordon is intended for local machine usage.
151 158
152 ## Internal Registry Auth159 ## 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 @@
3 Gordon has two backup flows:3 Gordon has two backup flows:
4 4
5 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.5 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
6-- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.6+- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. 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.
7 11
8 ## Database backups12 ## Database backups
9 13
@@ -64,19 +68,24 @@ keep = 14
64 | `backups.volumes.s3.prefix` | `""` | Object key prefix |68 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
65 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |69 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
66 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |70 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
67-| `backups.volumes.retention.keep` | `14` | Completed archives to keep per domain + volume |71+| `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |
68 72
69-Volume backup objects are stored under:73+Volume backup objects are stored under the canonical app name:
70 74
71 ```text75 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip76+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd77+<prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
74 ```78 ```
75 79
80+Objects written by earlier versions under a domain prefix are not read or
81+migrated: back them up and re-run the app's volume backups if you need them in
82+the current layout.
83+
76 ## Notes84 ## Notes
77 85
78-- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.86+- Volume backups archive the app's declared volumes that its backup declaration references.
79-- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.87+- Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.
88+- Database backups archive the app's declared PostgreSQL databases that its backup declaration references.
80 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.89 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
81 - Automated volume restore is not part of the MVP.90 - Automated volume restore is not part of the MVP.
82 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.91 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
@@ -84,6 +93,6 @@ Volume backup objects are stored under:
84 93
85 ## Related94 ## Related
86 95
87-- [Attachments Configuration](./attachments.md)96+- [App Manifest](./apps.md)
88 - [CLI Backup Command](../cli/backup.md)97 - [CLI Backup Command](../cli/backup.md)
89 - [Configuration Reference](./reference.md)98 - [Configuration Reference](./reference.md)
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
79 79
80 ## Related80 ## Related
81 81
82-- [Routes Configuration](./routes.md)82+- [App Manifest](./apps.md)
83 - [Configuration Overview](./index.md)83 - [Configuration Overview](./index.md)
M docs/config/images.md +21 -6
@@ -6,18 +6,20 @@ Configure automatic image cleanup for Docker runtime images and local registry s
6 6
7 When enabled, Gordon runs a scheduled image prune job that:7 When enabled, Gordon runs a scheduled image prune job that:
8 8
9-- Prunes dangling runtime images.9+- Prunes dangling runtime images that carry positive Gordon provenance.
10 - Applies tag retention to registry repositories.10 - Applies tag retention to registry repositories.
11 - Preserves the `latest` tag.11 - Preserves the `latest` tag.
12 - Removes unreferenced blobs after tag cleanup.12 - Removes unreferenced blobs after tag cleanup.
13+- Reports every protected and unknown candidate with its reason.
13 14
14-The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention).15+The CLI `gordon images prune` runs the same use case and planner as the scheduled job, with the same defaults (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). App state existing on the server is normal and never disables pruning; the plan decides per candidate.
15 16
16 ## Configuration17 ## Configuration
17 18
18 ```toml19 ```toml
19 [images]20 [images]
20-allowed_registries = []21+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are already allowed.
22+allowed_registries = ["registry.internal:5000"]
21 require_digest = false23 require_digest = false
22 24
23 [images.prune]25 [images.prune]
@@ -30,19 +32,32 @@ keep_last = 3
30 32
31 | Key | Type | Default | Description |33 | Key | Type | Default | Description |
32 |-----|------|---------|-------------|34 |-----|------|---------|-------------|
33-| `images.allowed_registries` | array | `[]` | Allowlist for external image registries. Empty means no external registries are accepted. Unqualified image names such as `nginx:latest` are treated as Docker Hub (`docker.io`) for policy checks. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |35+| `images.allowed_registries` | array | `[]` | Additional registry hostname+port entries. Docker Hub (`docker.io`, canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Include non-default ports, e.g. `"registry.example.com:5000"`. Hostnames are case-insensitive; one trailing dot and port `443` are canonicalized. This setting does not configure registry credentials. |
34-| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |36+| `images.require_digest` | bool | `false` | Require every image reference, including Gordon registry images, to use a valid `@sha256:<64 hex chars>` digest. |
35 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |37 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
36 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |38 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
37 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |39 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
38 40
41+The policy validates registry names and strict SHA-256 digest syntax at manifest apply, digest resolution, deployment preflight, and immediately before each pull. It rejects malformed, userinfo-bearing, and ambiguous authorities. External app images currently require digest-pinned references, independently of `require_digest`; external tag resolution is not available. External pulls are anonymous, so adding a host to `allowed_registries` does not enable authenticated private-registry access. This hostname allowlist does **not** prove that DNS resolves to a public address and does not constrain runtime egress; enforce destination-level restrictions in the host firewall or runtime network policy.
42+
39 ## Retention Behavior43 ## Retention Behavior
40 44
41 - `latest` is always preserved.45 - `latest` is always preserved.
42 - `keep_last` applies per repository and counts non-`latest` tags.46 - `keep_last` applies per repository and counts non-`latest` tags.
43-- `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).47+- `keep_last` sets a minimum retained set, not a maximum: a tag named by durable app state, or referenced by the OCI closure of a protected manifest, survives beyond the window.
48+- `keep_last = 0` skips registry tag/blob cleanup entirely (runtime dangling prune still runs).
44 - Negative `keep_last` values are invalid.49 - Negative `keep_last` values are invalid.
45 50
51+## Prune Safety
52+
53+A prune deletes only resources it can positively prove safe. Each candidate gets one verdict: `eligible` (deleted), `protected` (a durable fact claims it), or `unknown` (a required fact could not be read). Protected and unknown candidates are reported and left in place; an operation that deletes nothing is a success.
54+
55+Durable protection covers the DESIRED and ACTIVE state of every app, including stopped and partially converged services; recovery inhibitions; staged and committed apply intents; unfinished operation journal entries; runtime container use for running and stopped containers; recent uploads; and the transitive OCI closure of every retained manifest. Durable digest roots are repository-qualified, so moving a tag away from a deployed image keeps that manifest's config, layers, child manifests, and subject content protected. An unreadable, unsupported, or unresolvable manifest makes the blobs whose safety depended on it `unknown` instead of eligible.
56+
57+Registry content is repository-scoped: a blob is served only to a repository that completed an upload of that digest, and a manifest may only reference config, layers, and child manifests that repository owns. Image labels never authorize deletion of a runtime image; eligibility requires a matching released ownership claim from the app that pinned it, no container use, and a complete inventory.
58+
59+An exclusive GC lease serializes prune against app apply, deploy, start, restart, remove, recovery, and restore. Those paths hold a shared lease from validation/resource acquisition through the durable publication of that resource's protection, and an apply verifies the expected desired revision so a stale apply cannot overwrite a newer one.
60+
46 ## Related61 ## Related
47 62
48 - [CLI Images Command](../cli/images.md)63 - [CLI Images Command](../cli/images.md)
M docs/config/index.md +21 -72
@@ -19,12 +19,11 @@ gordon_domain = "gordon.mydomain.com"
19 [entrypoints.edge]19 [entrypoints.edge]
20 address = ":443"20 address = ":443"
21 protocol = "smart_tcp"21 protocol = "smart_tcp"
22-
23-[routes]
24-"app.mydomain.com" = "myapp:latest"
25 ```22 ```
26 23
27-> **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.24+Application workloads live in standalone app files, not in `gordon.toml` — see [App Manifest](./apps.md). Retired workload keys such as `[routes]`, `[attachments]`, `[network_groups]`, `[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.
28 >27 >
29 > For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename).28 > For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename).
30 29
@@ -61,16 +60,6 @@ per_ip_rps = 50 # Max requests/second per IP
61 burst = 100 # Burst size60 burst = 100 # Burst size
62 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For61 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
63 62
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-
74 # Container runtime profile63 # Container runtime profile
75 [containers]64 [containers]
76 security_profile = "compat" # compat or strict65 security_profile = "compat" # compat or strict
@@ -87,12 +76,8 @@ max_size = 100 # MB before rotation
87 max_backups = 3 # Old files to keep76 max_backups = 3 # Old files to keep
88 max_age = 28 # Days to keep77 max_age = 28 # Days to keep
89 78
90-[logging.container_logs]79+# Workload logs are streamed directly from the container runtime with
91-enabled = true80+# `gordon apps logs APP --service SERVICE`.
92-dir = "~/.gordon/logs/containers" # Default location
93-max_size = 100
94-max_backups = 3
95-max_age = 28
96 81
97 # Telemetry (OpenTelemetry)82 # Telemetry (OpenTelemetry)
98 [telemetry]83 [telemetry]
@@ -101,42 +86,24 @@ endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint
101 auth_token = "" # Base64 user:password for Basic auth86 auth_token = "" # Base64 user:password for Basic auth
102 traces = true # Export traces87 traces = true # Export traces
103 metrics = true # Export metrics88 metrics = true # Export metrics
104-logs = true # Bridge zerolog to OTLP logs89+logs = true # Export Gordon, access, and app logs
105 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all90 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
106 91
107-# Environment variables
108-[env]
109-dir = "~/.gordon/env" # Default location
110-
111 # Volume settings92 # Volume settings
112 [volumes]93 [volumes]
113 auto_create = true # Auto-create from Dockerfile VOLUME94 auto_create = true # Auto-create from Dockerfile VOLUME
114 prefix = "gordon" # Volume name prefix95 prefix = "gordon" # Volume name prefix
115 preserve = true # Keep volumes on container removal96 preserve = true # Keep volumes on container removal
116 97
117-# Network isolation98+# Installation network policy (prefix filter for `gordon daemon networks`)
118 [network_isolation]99 [network_isolation]
119-enabled = true # Per-app isolated networks100+enabled = true # Gordon-managed network policy
120 network_prefix = "gordon" # Network name prefix101 network_prefix = "gordon" # Network name prefix
121 internal = false # Set true to block direct egress from isolated networks102 internal = false # Set true to block direct egress from isolated networks
122 103
123-# Auto-route104+# REMOVED in v3 (declare apps in standalone files, see ./apps.md):
124-[auto_route]105+# [routes], [attachments], [network_groups],
125-enabled = false # Auto-create routes from image names106+# [service_routes], [auto_route], [previews]
126-
127-# Routes (required)
128-[routes]
129-"app.mydomain.com" = "myapp:latest"
130-"api.mydomain.com" = "myapi:v2.1.0"
131-
132-# Network groups
133-[network_groups]
134-"backend" = ["app.mydomain.com", "api.mydomain.com"]
135-
136-# Attachments
137-[attachments]
138-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
139-"backend" = ["rabbitmq:latest"]
140 107
141 # Backups108 # Backups
142 [backups]109 [backups]
@@ -152,8 +119,8 @@ monthly = 12
152 119
153 # Images120 # Images
154 [images]121 [images]
155-allowed_registries = [] # Explicit external registries allowed for deploy/attachments122+allowed_registries = [] # Additional exact registry hostname+port entries
156-require_digest = false # Require digests for allowlisted external registries123+require_digest = false # Require SHA-256 digests for every image registry
157 124
158 [images.prune]125 [images.prune]
159 enabled = false126 enabled = false
@@ -161,6 +128,8 @@ schedule = "daily"
161 keep_last = 3128 keep_last = 3
162 ```129 ```
163 130
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+
164 ## Configuration Sections133 ## Configuration Sections
165 134
166 | Section | Description | Documentation |135 | Section | Description | Documentation |
@@ -168,19 +137,14 @@ keep_last = 3
168 | `[server]` | Core server settings | [Server](./server.md) |137 | `[server]` | Core server settings | [Server](./server.md) |
169 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |138 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
170 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |139 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
171-| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
172 | `[logging]` | Logging configuration | [Logging](./logging.md) |140 | `[logging]` | Logging configuration | [Logging](./logging.md) |
173 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |141 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
174-| `[env]` | Environment variable settings | [Environment](./env.md) |
175 | `[volumes]` | Volume management | [Volumes](./volumes.md) |142 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
176-| `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |143+| `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |
177-| `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
178-| `[routes]` | Domain to image mapping | [Routes](./routes.md) |
179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |144 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180 | `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |145 | `[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) |146+| App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) |
182-| `[attachments]` | Service dependencies | [Attachments](./attachments.md) |147+| `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) |
183-| `[backups]` | Database backups | [Backups](./backups.md) |
184 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |148 | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
185 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |149 | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) |
186 150
@@ -200,13 +164,6 @@ keep_last = 3
200 | `api.rate_limit.per_ip_rps` | `50` |164 | `api.rate_limit.per_ip_rps` | `50` |
201 | `api.rate_limit.burst` | `100` |165 | `api.rate_limit.burst` | `100` |
202 | `api.rate_limit.trusted_proxies` | `[]` |166 | `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"` |
210 | `containers.security_profile` | `"compat"` |167 | `containers.security_profile` | `"compat"` |
211 | `logging.level` | `"info"` |168 | `logging.level` | `"info"` |
212 | `logging.format` | `"console"` |169 | `logging.format` | `"console"` |
@@ -214,13 +171,11 @@ keep_last = 3
214 | `logging.file.max_size` | `100` |171 | `logging.file.max_size` | `100` |
215 | `logging.file.max_backups` | `3` |172 | `logging.file.max_backups` | `3` |
216 | `logging.file.max_age` | `28` |173 | `logging.file.max_age` | `28` |
217-| `logging.container_logs.enabled` | `true` |
218 | `volumes.auto_create` | `true` |174 | `volumes.auto_create` | `true` |
219 | `volumes.prefix` | `"gordon"` |175 | `volumes.prefix` | `"gordon"` |
220 | `volumes.preserve` | `true` |176 | `volumes.preserve` | `true` |
221 | `network_isolation.enabled` | `true` |177 | `network_isolation.enabled` | `true` |
222 | `network_isolation.internal` | `false` |178 | `network_isolation.internal` | `false` |
223-| `auto_route.enabled` | `false` |
224 | `backups.enabled` | `false` |179 | `backups.enabled` | `false` |
225 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |180 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
226 | `images.allowed_registries` | `[]` |181 | `images.allowed_registries` | `[]` |
@@ -236,14 +191,14 @@ keep_last = 3
236 | `telemetry.logs` | `true` |191 | `telemetry.logs` | `true` |
237 | `telemetry.trace_sample_rate` | `1.0` |192 | `telemetry.trace_sample_rate` | `1.0` |
238 193
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).
240 195
241 ## Hot Reload196 ## Hot Reload
242 197
243 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:198 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
244 199
245 ```bash200 ```bash
246-gordon reload201+gordon daemon reload
247 ```202 ```
248 203
249 ### Hot-reloaded (no restart needed)204 ### Hot-reloaded (no restart needed)
@@ -256,7 +211,6 @@ gordon reload
256 | `server.max_proxy_response_size` |211 | `server.max_proxy_response_size` |
257 | `server.max_concurrent_conns` |212 | `server.max_concurrent_conns` |
258 213
259-> **Note:** Routes are hot-reloaded from the config file. You can still use the API or CLI (`gordon routes add/update/remove`) for live route changes.
260 214
261 ### Requires restart215 ### Requires restart
262 216
@@ -267,11 +221,6 @@ gordon reload
267 | `server.max_blob_chunk_size` |221 | `server.max_blob_chunk_size` |
268 | `server.max_blob_size` |222 | `server.max_blob_size` |
269 | `auth.*` |223 | `auth.*` |
270-| `deploy.readiness_mode` |
271-| `deploy.readiness_delay` |
272-| `deploy.health_timeout` |
273-| `deploy.drain_mode` |
274-| `deploy.drain_timeout` |
275 224
276 ## Environment Variable Override225 ## Environment Variable Override
277 226
@@ -286,7 +235,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
286 ## Related235 ## Related
287 236
288 - [Server Configuration](./server.md)237 - [Server Configuration](./server.md)
289-- [Routes Configuration](./routes.md)238+- [App Manifest](./apps.md)
290 - [External Routes](./external-routes.md)239 - [External Routes](./external-routes.md)
291 - [Standalone Services](./services.md)240 - [Standalone Services](./services.md)
292 - [Traffic Plane](./traffic.md)241 - [Traffic Plane](./traffic.md)
M docs/config/logging.md +21 -92
@@ -1,6 +1,6 @@
1 # Logging Configuration1 # Logging Configuration
2 2
3-Configure application and container log collection.3+Configure Gordon process and HTTP access logging. Workload logs are read directly from the container runtime.
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -16,13 +16,6 @@ max_size = 100
16 max_backups = 316 max_backups = 3
17 max_age = 2817 max_age = 28
18 18
19-[logging.container_logs]
20-enabled = true
21-dir = "~/.gordon/logs/containers"
22-max_size = 100
23-max_backups = 3
24-max_age = 28
25-
26 [logging.access_log]19 [logging.access_log]
27 enabled = false20 enabled = false
28 format = "json"21 format = "json"
@@ -44,24 +37,19 @@ syslog_identifier = "gordon-access"
44 37
45 | Option | Type | Default | Description |38 | Option | Type | Default | Description |
46 |--------|------|---------|-------------|39 |--------|------|---------|-------------|
47-| `file.enabled` | bool | `false` | Enable file-based logging |40+| `file.enabled` | bool | `false` | Enable process file logging |
48-| `file.path` | string | - | Path to main log file |41+| `file.path` | string | `{data_dir}/logs/gordon.log` | Process log path |
49 | `file.max_size` | int | `100` | Max file size in MB before rotation |42 | `file.max_size` | int | `100` | Max file size in MB before rotation |
50 | `file.max_backups` | int | `3` | Number of old files to keep |43 | `file.max_backups` | int | `3` | Number of old files to keep |
51 | `file.max_age` | int | `28` | Days to keep old files |44 | `file.max_age` | int | `28` | Days to keep old files |
52 45
53-The Admin API and `gordon logs` read from the process log file. Keep46+The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming.
54-`logging.file.enabled` set to `true` if you need process log streaming.
55 47
56-### Container Logs48+### Workload Logs
57 49
58-| Option | Type | Default | Description |50+`gordon apps logs APP --service SERVICE` reads stdout and stderr directly from the container runtime. Gordon does not persist workload logs to files; configure retention in the container runtime's logging driver.
59-|--------|------|---------|-------------|51+
60-| `container_logs.enabled` | bool | `true` | Collect container stdout/stderr |52+The accepted `logging.container_logs` configuration fields are currently not connected to a production log sink and do not create files.
61-| `container_logs.dir` | string | `{data_dir}/logs/containers` | Directory for container logs |
62-| `container_logs.max_size` | int | `100` | Max file size in MB |
63-| `container_logs.max_backups` | int | `3` | Old files to keep |
64-| `container_logs.max_age` | int | `28` | Days to keep files |
65 53
66 ### Access Log54 ### Access Log
67 55
@@ -89,31 +77,9 @@ Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingesti
89 | `warn` | Warnings |77 | `warn` | Warnings |
90 | `error` | Errors only |78 | `error` | Errors only |
91 79
92-## Log Directory Structure
93-
94-```
95-~/.gordon/logs/
96-├── gordon.log # Main application logs
97-├── gordon.log.1 # Rotated log
98-├── gordon.log.2.gz # Compressed old log
99-└── containers/
100- ├── app_mydomain_com.log # Container logs by domain
101- └── api_mydomain_com.log
102-```
103-
104 ## Log Rotation80 ## Log Rotation
105 81
106-Gordon uses automatic log rotation:82+Process file logs and file-based access logs rotate by size and retain files by count and age. Old files are compressed.
107-
108-1. **Size-based**: Rotates when file exceeds `max_size` MB
109-2. **Age-based**: Removes files older than `max_age` days
110-3. **Count-based**: Keeps only `max_backups` old files
111-4. **Compression**: Old files are gzip compressed
112-
113-Example with defaults:
114-- Log grows to 100MB → rotated to `gordon.log.1`
115-- After 3 rotations → oldest is compressed
116-- Files older than 28 days → deleted
117 83
118 ## Examples84 ## Examples
119 85
@@ -131,13 +97,6 @@ max_size = 10
131 max_backups = 297 max_backups = 2
132 max_age = 798 max_age = 7
133 99
134-[logging.container_logs]
135-enabled = true
136-dir = "./logs/containers"
137-max_size = 10
138-max_backups = 2
139-max_age = 7
140-
141 [logging.access_log]100 [logging.access_log]
142 enabled = true101 enabled = true
143 format = "json"102 format = "json"
@@ -160,12 +119,11 @@ max_size = 100
160 max_backups = 10119 max_backups = 10
161 max_age = 90120 max_age = 90
162 121
163-[logging.container_logs]122+[logging.access_log]
164 enabled = true123 enabled = true
165-dir = "~/.gordon/logs/containers"124+format = "json"
166-max_size = 100125+output = "journald"
167-max_backups = 10126+syslog_identifier = "gordon-access"
168-max_age = 90
169 ```127 ```
170 128
171 ### Minimal (Console Only)129 ### Minimal (Console Only)
@@ -177,60 +135,31 @@ format = "console"
177 135
178 [logging.file]136 [logging.file]
179 enabled = false137 enabled = false
180-
181-[logging.container_logs]
182-enabled = true
183 ```138 ```
184 139
185 ## Viewing Logs140 ## Viewing Logs
186 141
187-### Gordon Logs142+### Gordon Process Logs
188 143
189 ```bash144 ```bash
190-# Using gordon logs command145+gordon daemon logs -f
191-gordon logs -f # Follow logs146+gordon daemon logs -n 100
192-gordon logs -n 100 # Last 100 lines
193-
194-# Direct file access
195 tail -f ~/.gordon/logs/gordon.log147 tail -f ~/.gordon/logs/gordon.log
196-
197-# With journalctl (if using systemd)
198 journalctl --user -u gordon -f148 journalctl --user -u gordon -f
199 ```149 ```
200 150
201-### Container Logs151+### Workload Logs
202 152
203 ```bash153 ```bash
204-# View container logs154+gordon apps logs blog --service web
205-tail -f ~/.gordon/logs/containers/app_mydomain_com.log155+gordon apps logs blog --service web --follow
206-
207-# All container logs
208-ls ~/.gordon/logs/containers/
209 ```156 ```
210 157
211-## Log Format158+These commands stream from the container runtime; they do not read Gordon-managed workload log files.
212-
213-### Console Format
214-
215-```
216-2024-01-15T10:30:00Z INF container deployed domain=app.mydomain.com image=myapp:latest
217-2024-01-15T10:30:01Z INF proxy routing updated route=app.mydomain.com
218-```
219-
220-### JSON Format
221-
222-```json
223-{"level":"info","time":"2024-01-15T10:30:00Z","message":"container deployed","domain":"app.mydomain.com","image":"myapp:latest"}
224-{"level":"info","time":"2024-01-15T10:30:01Z","message":"proxy routing updated","route":"app.mydomain.com"}
225-```
226 159
227 ## Security160 ## Security
228 161
229-Log files are created with secure permissions:162+Process and access log files are created with owner-only permissions. Gordon redacts common credential patterns when serving logs, but application output may still contain sensitive data. Restrict process-log, journal, runtime, and `admin:logs:read` access.
230-- Directories: `0700` (owner only)
231-- Files: `0600` (owner only)
232-
233-Sensitive values (passwords, tokens) are automatically redacted.
234 163
235 ## Related164 ## Related
236 165
D docs/config/network-groups.md +0 -188
@@ -1,188 +0,0 @@
1-# Network Groups
2-
3-Group multiple applications into shared networks for inter-service communication.
4-
5-## Configuration
6-
7-```toml
8-[network_groups]
9-"backend" = ["app.mydomain.com", "api.mydomain.com"]
10-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com"]
11-```
12-
13-## Syntax
14-
15-```toml
16-[network_groups]
17-"<group-name>" = ["<domain>", "<domain>", ...]
18-```
19-
20-| Component | Description |
21-|-----------|-------------|
22-| `group-name` | Name for the shared network |
23-| `domains` | List of routes that share this network |
24-
25-## How It Works
26-
27-Network groups create shared Docker networks for multiple applications:
28-
29-```toml
30-[network_groups]
31-"backend" = ["app.mydomain.com", "api.mydomain.com"]
32-```
33-
34-Creates a shared network: `gordon-backend`
35-
36-```
37-┌────────────────────────────────────────────────┐
38-│ gordon-backend │
39-│ │
40-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
41-│ │ App │←──→│ API │←──→│ Postgres │ │
42-│ │ :3000 │ │ :8080 │ │ :5432 │ │
43-│ └──────────┘ └──────────┘ └──────────┘ │
44-│ │
45-└────────────────────────────────────────────────┘
46-```
47-
48-## Service Discovery
49-
50-Applications in the same group can communicate using container names:
51-
52-```toml
53-[routes]
54-"app.mydomain.com" = "frontend:latest"
55-"api.mydomain.com" = "backend:latest"
56-
57-[network_groups]
58-"services" = ["app.mydomain.com", "api.mydomain.com"]
59-```
60-
61-Server-side code (SSR, API routes) can reach other containers by name:
62-```javascript
63-// In server-side code (SSR / API route) - NOT browser JavaScript
64-const response = await fetch("http://backend:8080/api/data");
65-```
66-
67-Browser clients should use the public route instead:
68-```javascript
69-// In browser JavaScript
70-const response = await fetch("https://api.mydomain.com/api/data");
71-```
72-
73-## Shared Attachments
74-
75-Combine network groups with attachments for shared services:
76-
77-```toml
78-[network_groups]
79-"backend" = ["app.mydomain.com", "api.mydomain.com"]
80-
81-[attachments]
82-"backend" = ["postgres:latest", "redis:latest"]
83-```
84-
85-Both `app.mydomain.com` and `api.mydomain.com` can access:
86-- `postgres:5432`
87-- `redis:6379`
88-
89-## Multiple Groups
90-
91-An application can only be in one network group:
92-
93-```toml
94-# Each app belongs to one group
95-[network_groups]
96-"customer-facing" = ["app.mydomain.com", "api.mydomain.com"]
97-"internal" = ["admin.mydomain.com", "metrics.mydomain.com"]
98-```
99-
100-## Mixed Isolation
101-
102-Combine network groups with per-app attachments:
103-
104-```toml
105-[network_groups]
106-"backend" = ["app.mydomain.com", "api.mydomain.com"]
107-
108-[attachments]
109-# Shared by both apps
110-"backend" = ["redis:latest", "rabbitmq:latest"]
111-
112-# App-specific databases
113-"app.mydomain.com" = ["app-postgres:latest"]
114-"api.mydomain.com" = ["api-postgres:latest"]
115-```
116-
117-Result:
118-- Shared: Redis and RabbitMQ
119-- Isolated: Each app has its own Postgres
120-
121-## Examples
122-
123-### Microservices Architecture
124-
125-```toml
126-[routes]
127-"orders.mydomain.com" = "orders-service:latest"
128-"inventory.mydomain.com" = "inventory-service:latest"
129-"shipping.mydomain.com" = "shipping-service:latest"
130-"payments.mydomain.com" = "payments-service:latest"
131-
132-[network_groups]
133-"order-processing" = ["orders.mydomain.com", "inventory.mydomain.com", "shipping.mydomain.com"]
134-"payment-processing" = ["payments.mydomain.com"]
135-
136-[attachments]
137-"order-processing" = ["rabbitmq:latest"]
138-"payments.mydomain.com" = ["payments-db:latest"]
139-```
140-
141-### Monitoring Stack
142-
143-```toml
144-[routes]
145-"grafana.mydomain.com" = "grafana/grafana:latest"
146-"prometheus.mydomain.com" = "prom/prometheus:latest"
147-"alertmanager.mydomain.com" = "prom/alertmanager:latest"
148-
149-[network_groups]
150-"monitoring" = ["grafana.mydomain.com", "prometheus.mydomain.com", "alertmanager.mydomain.com"]
151-
152-[attachments]
153-"monitoring" = ["prometheus-data:latest"]
154-```
155-
156-### Multi-Tenant SaaS
157-
158-```toml
159-[routes]
160-"app.saas.com" = "saas-app:latest"
161-"api.saas.com" = "saas-api:latest"
162-"admin.saas.com" = "saas-admin:latest"
163-
164-# API and App share backend services
165-[network_groups]
166-"platform" = ["app.saas.com", "api.saas.com"]
167-
168-# Admin is isolated
169-[attachments]
170-"platform" = ["postgres:latest", "redis:latest", "elasticsearch:latest"]
171-"admin.saas.com" = ["admin-db:latest"]
172-```
173-
174-## Network Naming
175-
176-Group networks are named: `{network_prefix}-{group-name}`
177-
178-| Group | Network Name |
179-|-------|--------------|
180-| `backend` | `gordon-backend` |
181-| `monitoring` | `gordon-monitoring` |
182-| `order-processing` | `gordon-order-processing` |
183-
184-## Related
185-
186-- [Network Isolation](./network-isolation.md)
187-- [Attachments](./attachments.md)
188-- [Routes](./routes.md)
M docs/config/network-isolation.md +13 -150
@@ -1,6 +1,6 @@
1 # Network Isolation1 # Network Isolation
2 2
3-Isolate applications in separate Docker networks for enhanced security.3+Installation network policy for Gordon-managed networks.
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -11,177 +11,40 @@ network_prefix = "gordon"
11 internal = false11 internal = false
12 ```12 ```
13 13
14-## Migration Note
15-
16-As of this release, `network_isolation.enabled` defaults to `true` (previously `false`).
17-Existing installs that rely on a shared network must explicitly opt out:
18-
19-```toml
20-[network_isolation]
21-enabled = false
22-```
23-
24 ## Options14 ## Options
25 15
26 | Option | Type | Default | Description |16 | Option | Type | Default | Description |
27 |--------|------|---------|-------------|17 |--------|------|---------|-------------|
28-| `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |18+| `enabled` | bool | `true` | Enable Gordon-managed network policy |
29-| `network_prefix` | string | `"gordon"` | Prefix for created networks |19+| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon 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. |20+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
31-
32-## How It Works
33-
34-When network isolation is enabled, each application gets its own Docker network:
35-
36-```
37-[network_isolation]
38-enabled = true
39-network_prefix = "gordon"
40-
41-[routes]
42-"app.mydomain.com" = "myapp:latest"
43-"api.mydomain.com" = "myapi:latest"
44-```
45-
46-Creates two isolated networks:
47-- `gordon-app-mydomain-com`
48-- `gordon-api-mydomain-com`
49-
50-## Network Naming
51-
52-Networks are named: `{prefix}-{domain-with-dashes}`
53-
54-| Domain | Network Name |
55-|--------|--------------|
56-| `app.mydomain.com` | `gordon-app-mydomain-com` |
57-| `api.company.io` | `gordon-api-company-io` |
58-| `staging.app.dev` | `gordon-staging-app-dev` |
59-
60-## Security Benefits
61-
62-### Without Network Isolation
63-
64-All containers can potentially communicate:
65-
66-```
67-┌───────────────────────────────────────┐
68-│ Default Bridge Network │
69-│ │
70-│ App A ←──────→ App B ←──────→ App C │
71-│ ↕ ↕ ↕ │
72-│ DB A ←──────→ DB B ←──────→ DB C │
73-│ │
74-└───────────────────────────────────────┘
75-```
76-
77-### With Network Isolation
78-
79-Each app is isolated with its dependencies:
80-
81-```
82-┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
83-│ gordon-app-a │ │ gordon-app-b │ │ gordon-app-c │
84-│ │ │ │ │ │
85-│ App A ←→ DB A │ │ App B ←→ DB B │ │ App C ←→ DB C │
86-│ │ │ │ │ │
87-└─────────────────┘ └─────────────────┘ └─────────────────┘
88- ↑ ↑ ↑
89- └───────── No direct communication ─────┘
90-```
91-
92-## Service Discovery
93-
94-Within an isolated network, services are discoverable by name:
95 21
96-```toml22+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.
97-[network_isolation]
98-enabled = true
99-
100-[attachments]
101-"app.mydomain.com" = ["postgres:latest", "redis:latest"]
102-```
103-
104-Your application connects using simple hostnames:
105-
106-```python
107-# These work within the isolated network
108-db = connect("postgresql://postgres:5432/mydb")
109-cache = connect("redis://redis:6379")
110-```
111-
112-## Examples
113-
114-### Basic Isolation
115-
116-```toml
117-[network_isolation]
118-enabled = true
119-
120-[routes]
121-"app.mydomain.com" = "myapp:latest"
122-"api.mydomain.com" = "myapi:latest"
123-
124-[attachments]
125-"app.mydomain.com" = ["app-postgres:latest"]
126-"api.mydomain.com" = ["api-postgres:latest"]
127-```
128-
129-Each app gets its own network with its own database.
130-
131-### Production Configuration
132-
133-```toml
134-[network_isolation]
135-enabled = true
136-network_prefix = "prod"
137-
138-[routes]
139-"app.company.com" = "company-app:v2.1.0"
140-"api.company.com" = "company-api:v1.5.0"
141-"admin.company.com" = "admin-panel:v1.0.0"
142-```
143-
144-Creates networks:
145-- `prod-app-company-com`
146-- `prod-api-company-com`
147-- `prod-admin-company-com`
148-
149-### Shared Services with Network Groups
150-
151-When apps need to communicate, use network groups:
152 23
153-```toml24+## Service-to-Service Networking
154-[network_isolation]
155-enabled = true
156 25
157-[network_groups]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.
158-"backend" = ["app.mydomain.com", "api.mydomain.com"]
159 27
160-[attachments]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.
161-"backend" = ["shared-postgres:latest", "shared-redis:latest"]
162-```
163 29
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.
165 31
166 ## Inspecting Networks32 ## Inspecting Networks
167 33
168-View created networks:34+View Gordon-managed networks:
169 35
170 ```bash36 ```bash
37+gordon daemon networks
171 docker network ls | grep gordon38 docker network ls | grep gordon
172-# gordon-app-mydomain-com
173-# gordon-api-mydomain-com
174-# gordon-backend
175 ```39 ```
176 40
177 Inspect a network:41 Inspect a network:
178 42
179 ```bash43 ```bash
180-docker network inspect gordon-app-mydomain-com44+docker network inspect <network-name>
181 ```45 ```
182 46
183 ## Related47 ## Related
184 48
185-- [Network Groups](./network-groups.md)49+- [App Manifest](./apps.md)
186-- [Attachments](./attachments.md)
187 - [Configuration Overview](./index.md)50 - [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
83 max_backups = 3 # Number of old files to keep83 max_backups = 3 # Number of old files to keep
84 max_age = 28 # Days to keep old files84 max_age = 28 # Days to keep old files
85 85
86-[logging.container_logs]86+# Workload logs are streamed from the container runtime with
87-enabled = true # Enable container log collection87+# `gordon apps logs APP --service SERVICE`. The accepted
88-dir = "" # Log directory (default: {data_dir}/container-logs)88+# logging.container_logs fields are not connected to a production file sink.
89-max_size = 100 # Max size in MB before rotation
90-max_backups = 3 # Number of old files to keep
91-max_age = 28 # Days to keep old files
92 89
93 [logging.access_log]90 [logging.access_log]
94 enabled = false # Dedicated HTTP access log for reverse-proxy traffic91 enabled = false # Dedicated HTTP access log for reverse-proxy traffic
@@ -110,45 +107,21 @@ endpoint = "" # OTLP HTTP endpoint URL
110 auth_token = "" # Base64-encoded user:password for Basic auth107 auth_token = "" # Base64-encoded user:password for Basic auth
111 traces = true # Export distributed traces108 traces = true # Export distributed traces
112 metrics = true # Export metrics109 metrics = true # Export metrics
113-logs = true # Bridge zerolog output to OTLP logs110+logs = true # Export Gordon, access, and app logs
114 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
115 112
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-
134 # =============================================================================113 # =============================================================================
135 # CONTAINERS114 # CONTAINERS
136 # =============================================================================115 # =============================================================================
137 [containers]116 [containers]
138 security_profile = "compat" # "compat" or "strict"117 security_profile = "compat" # "compat" or "strict"
139 118
140-# =============================================================================
141-# AUTO-ROUTE
142-# =============================================================================
143-[auto_route]
144-enabled = false # Create routes from image labels automatically
145-
146 # =============================================================================119 # =============================================================================
147 # NETWORK ISOLATION120 # NETWORK ISOLATION
148 # =============================================================================121 # =============================================================================
149 [network_isolation]122 [network_isolation]
150-enabled = true # Enable per-app Docker networks123+enabled = true # Installation network policy for Gordon-managed networks
151-network_prefix = "gordon" # Prefix for created networks124+network_prefix = "gordon" # Prefix filter for `gordon daemon networks`
152 internal = false # Create Docker internal networks (blocks direct egress)125 internal = false # Create Docker internal networks (blocks direct egress)
153 126
154 # =============================================================================127 # =============================================================================
@@ -160,12 +133,38 @@ prefix = "gordon" # Volume name prefix
160 preserve = true # Keep volumes when containers are removed133 preserve = true # Keep volumes when containers are removed
161 134
162 # =============================================================================135 # =============================================================================
163-# ROUTES136+# 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)
164 # =============================================================================152 # =============================================================================
165-[routes]153+# A named device grant is the only way an app manifest may request host
166-# "domain.com" = { image = "image:tag" }154+# devices. Manifests declare devices = ["<name>"]; raw /dev paths are
167-# "insecure.domain.com" = { image = "image:tag", https = false }155+# always rejected, and the policy's allowlists are exact and non-empty.
168-# Legacy "http://domain.com" keys are read for compatibility and rewritten on save.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.
169 168
170 # =============================================================================169 # =============================================================================
171 # EXTERNAL ROUTES170 # EXTERNAL ROUTES
@@ -195,18 +194,6 @@ preserve = true # Keep volumes when containers are
195 # publish = "127.0.0.1:38016"194 # publish = "127.0.0.1:38016"
196 # trusted_cidrs = ["100.64.0.0/10"]195 # trusted_cidrs = ["100.64.0.0/10"]
197 196
198-# =============================================================================
199-# NETWORK GROUPS
200-# =============================================================================
201-[network_groups]
202-# "group-name" = ["domain1.com", "domain2.com"]
203-
204-# =============================================================================
205-# ATTACHMENTS
206-# =============================================================================
207-[attachments]
208-# "domain-or-group" = ["image1:tag", "image2:tag"]
209-
210 # =============================================================================197 # =============================================================================
211 # BACKUPS198 # BACKUPS
212 # =============================================================================199 # =============================================================================
@@ -225,6 +212,8 @@ monthly = 0 # Keep N monthly backups per DB
225 # IMAGES212 # IMAGES
226 # =============================================================================213 # =============================================================================
227 [images]214 [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.
228 allowed_registries = []217 allowed_registries = []
229 require_digest = false218 require_digest = false
230 219
@@ -265,7 +254,7 @@ keep_last = 3 # Keep N newest tags per repository
265 | `tls.acme.email` | `""` | ACME account email when enabled |254 | `tls.acme.email` | `""` | ACME account email when enabled |
266 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |255 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
267 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |256 | `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`) |
269 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |258 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
270 | `auth.token_expiry` | `"30d"` | 30 days |259 | `auth.token_expiry` | `"30d"` | 30 days |
271 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |260 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
@@ -280,10 +269,6 @@ keep_last = 3 # Keep N newest tags per repository
280 | `logging.file.max_size` | `100` | 100 MB |269 | `logging.file.max_size` | `100` | 100 MB |
281 | `logging.file.max_backups` | `3` | Keep 3 old files |270 | `logging.file.max_backups` | `3` | Keep 3 old files |
282 | `logging.file.max_age` | `28` | 28 days |271 | `logging.file.max_age` | `28` | 28 days |
283-| `logging.container_logs.enabled` | `true` | Container logs enabled |
284-| `logging.container_logs.max_size` | `100` | 100 MB |
285-| `logging.container_logs.max_backups` | `3` | Keep 3 old files |
286-| `logging.container_logs.max_age` | `28` | 28 days |
287 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |272 | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
288 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |273 | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
289 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |274 | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
@@ -297,23 +282,23 @@ keep_last = 3 # Keep N newest tags per repository
297 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |282 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
298 | `telemetry.traces` | `true` | Export distributed traces |283 | `telemetry.traces` | `true` | Export distributed traces |
299 | `telemetry.metrics` | `true` | Export metrics |284 | `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 |
301 | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |286 | `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 |
309 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |287 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
310-| `auto_route.enabled` | `false` | Auto-route disabled |
311 | `network_isolation.enabled` | `true` | Network isolation enabled |288 | `network_isolation.enabled` | `true` | Network isolation enabled |
312 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |289 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
313 | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |290 | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
314 | `volumes.auto_create` | `true` | Auto-create volumes |291 | `volumes.auto_create` | `true` | Auto-create volumes |
315 | `volumes.prefix` | `"gordon"` | Volume prefix |292 | `volumes.prefix` | `"gordon"` | Volume prefix |
316 | `volumes.preserve` | `true` | Keep volumes |293 | `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 |
317 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |302 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
318 | `services[].image` | none | Container image for enabled standalone services |303 | `services[].image` | none | Container image for enabled standalone services |
319 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |304 | `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
342 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |327 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
343 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |328 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
344 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |329 | `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 |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. |
346-| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |331+| `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
347 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |332 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
348 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |333 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
349 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |334 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
350 335
351 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.336 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
352 337
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+
353 ## Environment Variables351 ## Environment Variables
354 352
355 All configuration options can be set via environment variables using the pattern:353 All configuration options can be set via environment variables using the pattern:
@@ -419,5 +417,6 @@ gordon serve
419 - [Telemetry](./telemetry.md)417 - [Telemetry](./telemetry.md)
420 - [Network Isolation](./network-isolation.md)418 - [Network Isolation](./network-isolation.md)
421 - [Volumes](./volumes.md)419 - [Volumes](./volumes.md)
420+- [App Manifest](./apps.md)
422 - [Standalone Services](./services.md)421 - [Standalone Services](./services.md)
423 - [Images](./images.md)422 - [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 @@
1 # Secrets Configuration1 # Secrets Configuration
2 2
3-Configure how Gordon stores and retrieves sensitive data.3+Configure how Gordon stores and retrieves sensitive data: 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>`).
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -52,12 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
52 - Standard Unix tooling52 - Standard Unix tooling
53 - Works with team GPG keys53 - Works with team GPG keys
54 54
55-**Route secrets storage:**
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-
61 ### SOPS55 ### SOPS
62 56
63 Uses Mozilla SOPS for encrypted file-based secrets:57 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -77,13 +71,6 @@ brew install sops # macOS
77 sops secrets.yaml71 sops secrets.yaml
78 ```72 ```
79 73
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-
87 **Benefits:**74 **Benefits:**
88 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)75 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)
89 - YAML/JSON file encryption76 - YAML/JSON file encryption
@@ -94,15 +81,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
94 - Path traversal (`..`) is blocked81 - Path traversal (`..`) is blocked
95 - Only relative paths from your config directory are allowed82 - Only relative paths from your config directory are allowed
96 83
97-**Route secrets storage:**
98-- Domain secrets stay in `.env` files
99-- Use `${sops:...}` syntax to resolve encrypted values
100-
101-**Attachment secrets storage:**
102-- Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files
103-- Use `${sops:...}` syntax inside attachment env files to resolve encrypted values
104-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
105-
106 ### Unsafe (Development Only)84 ### Unsafe (Development Only)
107 85
108 Stores secrets as plain text files:86 Stores secrets as plain text files:
@@ -120,11 +98,6 @@ secrets_backend = "unsafe"
120 │ └── token_secret98 │ └── token_secret
121 ```99 ```
122 100
123-**Attachment secrets:**
124-- Stored as `gordon-<container-name>.env` files in the env directory
125-- Example: `gordon-app__mydomain__com-postgres.env`
126-- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
127-
128 **Usage:**101 **Usage:**
129 ```bash102 ```bash
130 # Create secret103 # Create secret
@@ -134,25 +107,15 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret
134 107
135 > **Warning:** Only use for local development. Secrets are stored in plain text.108 > **Warning:** Only use for local development. Secrets are stored in plain text.
136 109
137-## Secret Provider Syntax110+## App Secret Values
138-
139-In environment files, reference secrets using provider syntax:
140 111
141-### Pass Provider112+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:
142 113
143 ```bash114 ```bash
144-# ${pass:<path>}115+gordon apps secrets set blog --service web DATABASE_URL=...
145-DATABASE_PASSWORD=${pass:myapp/database/password}
146-API_KEY=${pass:myapp/api-key}
147 ```116 ```
148 117
149-### SOPS Provider118+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).
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-```
156 119
157 ## Examples120 ## Examples
158 121
@@ -182,29 +145,10 @@ enabled = false
182 secrets_backend = "unsafe"145 secrets_backend = "unsafe"
183 ```146 ```
184 147
185-### Enterprise with SOPS
186-
187-```toml
188-[auth]
189-enabled = true
190-secrets_backend = "sops"
191-token_secret = "gordon/auth/token_secret"
192-```
193-
194-Environment file:
195-```bash
196-# ~/.gordon/env/app_company_com.env
197-NODE_ENV=production
198-DATABASE_URL=postgresql://db:5432/app
199-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
200-API_KEY=${sops:secrets.yaml:api.key}
201-JWT_SECRET=${sops:secrets.yaml:jwt.secret}
202-```
203-
204 ## Security Recommendations148 ## Security Recommendations
205 149
206 1. **Production**: Always use `pass` or `sops` backend150 1. **Production**: Always use `pass` or `sops` backend
207-2. **Never commit**: Don't commit unencrypted secrets to git151+2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values)
208 3. **Rotate regularly**: Regenerate tokens and passwords periodically152 3. **Rotate regularly**: Regenerate tokens and passwords periodically
209 4. **Least privilege**: Use separate secrets per environment153 4. **Least privilege**: Use separate secrets per environment
210 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security154 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}
212 ## Related156 ## Related
213 157
214 - [Authentication](./auth.md)158 - [Authentication](./auth.md)
215-- [Environment Variables](./env.md)159+- [App Manifest](./apps.md)
216 - [Configuration Overview](./index.md)160 - [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
24 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d24 gordon auth token generate --subject ops --scopes "admin:logs:read" --expiry 30d
25 ```25 ```
26 26
27-- `/admin/logs` and deploy failure logs require `admin:logs:read`.27+- `/admin/logs`, app failure diagnostics, and deploy failure logs require `admin:logs:read`.
28 - `admin:status:read` does not grant log access.28 - `admin:status:read` does not grant log access.
29-- Common secret patterns are redacted before logs are returned.29+- Operation errors returned to `admin:apps:read` callers are stable and log-free. Application diagnostics are a separate field, redacted of the affected service's resolved secret values before storage, and returned only to callers holding `admin:logs:read`. If a declared secret cannot be resolved for redaction, Gordon drops the diagnostics instead of storing unredacted output. Mutation responses never include application output.
30+- Process and container log sources remain operator-owned data. Gordon redacts common credential patterns when serving logs through its API, but this is defense in depth rather than proof that arbitrary application output is secret-free. Restrict filesystem, journal, runtime, backup, and `admin:logs:read` access accordingly.
30 31
31 ## Volume pruning scope32 ## Volume pruning scope
32 33
33-Volume pruning only removes unused Docker volumes explicitly managed by Gordon (`gordon.managed=true`). It ignores unrelated Docker volumes even if they are unused.34+Volume pruning removes a volume only when Gordon has a durable `released` ownership record, the runtime app/incarnation/service labels agree with that record, and no container mounts it. A `gordon.managed=true` label by itself is not deletion authority; retained, unknown, contradictory, and unrelated volumes survive.
35+
36+Use `gordon volumes prune --dry-run` before deletion. Do not substitute `docker volume prune` or an equivalent runtime command: runtime-native pruning bypasses Gordon's ownership and retention checks.
34 37
35 Use dedicated admin scopes:38 Use dedicated admin scopes:
36 39
37 - `admin:volumes:read` for listing volumes.40 - `admin:volumes:read` for listing volumes.
38 - `admin:volumes:write` for prune operations.41 - `admin:volumes:write` for prune operations.
39 42
40-## Pass migration plaintext handling43+## 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)
41 67
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.
43 85
44 ## External image registries86 ## External image registries
45 87
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:
47 89
48 ```toml90 ```toml
49 [images]91 [images]
50-allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"]92+allowed_registries = ["registry.internal:5000"]
51 require_digest = true93 require_digest = true
52 ```94 ```
53 95
54-- Empty `allowed_registries` rejects explicit external registries.96+- `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent.
55-- `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected.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.
56-- `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.98+- `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`.
57-- Include ports in allowlist entries when the registry uses a non-default port.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.
58 100
59 ## Smart TCP Raw Fallback101 ## Smart TCP Raw Fallback
60 102
@@ -95,7 +137,28 @@ enabled = true
95 internal = true137 internal = true
96 ```138 ```
97 139
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.
99 162
100 ## Container runtime profile163 ## Container runtime profile
101 164
@@ -114,6 +177,5 @@ Use `strict` for images designed to write only to mounted volumes and run withou
114 - [Auth](./auth.md)177 - [Auth](./auth.md)
115 - [Images](./images.md)178 - [Images](./images.md)
116 - [Network Isolation](./network-isolation.md)179 - [Network Isolation](./network-isolation.md)
117-- [Deploy](./deploy.md)
118 - [Volumes](./volumes.md)180 - [Volumes](./volumes.md)
119 - [Reference](./reference.md)181 - [Reference](./reference.md)
M docs/config/server.md +8 -12
@@ -32,7 +32,7 @@ protocol = "smart_tcp"
32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
34 | `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). |34 | `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 |
36 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |36 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
@@ -102,7 +102,7 @@ Challenge behavior:
102 102
103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
104 104
105-Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to configured HTTPS routes. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.105+Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to app HTTPS hosts. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
106 106
107 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.107 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
108 108
@@ -116,7 +116,7 @@ DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS vis
116 116
117 The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.117 The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.
118 118
119-Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.119+Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by app HTTPS hosts. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
120 120
121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)121 #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
122 122
@@ -128,7 +128,7 @@ When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (
128 128
129 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.129 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.
130 130
131-On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.131+On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts are served without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
132 132
133 #### HTTP to HTTPS redirect133 #### HTTP to HTTPS redirect
134 134
@@ -187,7 +187,7 @@ gordon_domain = "gordon.mydomain.com"
187 This domain is used for:187 This domain is used for:
188 - Docker login and image push/pull operations188 - Docker login and image push/pull operations
189 - Admin API access (`/admin/*` endpoints)189 - Admin API access (`/admin/*` endpoints)
190-- CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)190+- CLI remote targeting (`gordon apps --remote https://gordon.mydomain.com`)
191 - Authentication endpoints (`/auth/*`)191 - Authentication endpoints (`/auth/*`)
192 192
193 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.193 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
@@ -220,13 +220,9 @@ data_dir = "~/.gordon" # Default for user installations
220 ├── registry/ # Container images and manifests220 ├── registry/ # Container images and manifests
221 │ ├── blobs/221 │ ├── blobs/
222 │ └── manifests/222 │ └── manifests/
223-├── env/ # Environment files per domain223+├── logs/ # Gordon process and file-based access logs
224-│ ├── app_mydomain_com.env
225-│ └── api_mydomain_com.env
226-├── logs/ # Application and container logs
227 │ ├── gordon.log224 │ ├── gordon.log
228-│ ├── proxy.log225+│ └── access.log
229-│ └── containers/
230 └── secrets/ # Secrets (unsafe backend only)226 └── secrets/ # Secrets (unsafe backend only)
231 ```227 ```
232 228
@@ -430,6 +426,6 @@ sudo firewall-cmd --reload
430 426
431 - [Configuration Overview](./index.md)427 - [Configuration Overview](./index.md)
432 - [Installation Guide](../installation.md)428 - [Installation Guide](../installation.md)
433-- [Routes Configuration](./routes.md)429+- [App Manifest](./apps.md)
434 - [Standalone Services](./services.md)430 - [Standalone Services](./services.md)
435 - [Traffic Plane Configuration](./traffic.md)431 - [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
91 91
92 - [Traffic Plane Configuration](./traffic.md)92 - [Traffic Plane Configuration](./traffic.md)
93 - [Configuration Reference](./reference.md)93 - [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
26 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |26 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
27 | `traces` | bool | `true` | Export distributed traces |27 | `traces` | bool | `true` | Export distributed traces |
28 | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |28 | `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 |
30 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |30 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
31 31
32 ## How It Works32 ## How It Works
@@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled:
35 35
36 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.36 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.
37 2. **Metrics** -- Gordon records custom counters and histograms for deploys, container restarts, crash loops, managed container count, registry pushes, and event bus throughput.37 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)).
39 39
40 When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.40 When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.
41 41
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+
42 ## Endpoint URL91 ## Endpoint URL
43 92
44 The `endpoint` field accepts a full URL. Gordon parses it to extract:93 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
61 110
62 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.111 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
63 112
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:
65 114
66 | Method | How |115 | Method | How |
67 |--------|-----|116 |--------|-----|
@@ -98,9 +147,9 @@ Attributes: `domain`, `image`
98 |--------|------|------|-------------|147 |--------|------|------|-------------|
99 | `gordon.container.restarts` | Counter | - | Container restart count |148 | `gordon.container.restarts` | Counter | - | Container restart count |
100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |149 | `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 |
102 151
103-Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes152+Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.managed` has no attributes; tell instances apart with the `host.name` resource attribute.
104 153
105 ### Registry154 ### Registry
106 155
M docs/config/traffic.md +6 -6
@@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive packets
4 4
5 ## Smart TCP Edge Entrypoint5 ## Smart TCP Edge Entrypoint
6 6
7-Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, normal Gordon routes use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:7+Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, app HTTP hosts use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
8 8
9 ```toml9 ```toml
10 [entrypoints.edge]10 [entrypoints.edge]
@@ -15,7 +15,7 @@ trusted_cidrs = []
15 15
16 Do not treat `entrypoints.edge.address` as an HTTP port or an HTTPS port. It is one TCP socket that sniffs each new connection and dispatches the original byte stream.16 Do not treat `entrypoints.edge.address` as an HTTP port or an HTTPS port. It is one TCP socket that sniffs each new connection and dispatches the original byte stream.
17 17
18-Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve normal Gordon routes through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.18+Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve app HTTP hosts through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
19 19
20 ## Smart TCP Dispatch Order20 ## Smart TCP Dispatch Order
21 21
@@ -73,7 +73,7 @@ protocol = "tcp"
73 73
74 Service references use:74 Service references use:
75 75
76-- `route:<domain>` for configured HTTP routes76+- `route:<domain>` for app HTTP hosts from ACTIVE state (merged with installation external routes)
77 - `external_route:<domain>` for configured external HTTP routes77 - `external_route:<domain>` for configured external HTTP routes
78 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends78 - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends
79 - `service:<service>:<port-name>` for Gordon-managed standalone service backends79 - `service:<service>:<port-name>` for Gordon-managed standalone service backends
@@ -96,7 +96,7 @@ sni = "raw.example.com"
96 service = "network_service:raw:tls"96 service = "network_service:raw:tls"
97 ```97 ```
98 98
99-Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. HTTPS application routes that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.99+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. App HTTPS hosts that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
100 100
101 ## Raw TCP Fallback101 ## Raw TCP Fallback
102 102
@@ -176,6 +176,6 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
176 ## Related176 ## Related
177 177
178 - [Server Settings](./server.md)178 - [Server Settings](./server.md)
179-- [Routes](./routes.md)179+- [App Manifest](./apps.md)
180 - [Standalone Services](./services.md)180 - [Standalone Services](./services.md)
181-- [CLI traffic status](../cli/traffic.md)181+- [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic)
M docs/config/volumes.md +73 -112
@@ -1,165 +1,126 @@
1 # Volumes Configuration1 # Volumes Configuration
2 2
3-Configure automatic persistent storage for containers.3+Persistent app storage is declared in each app manifest. The installation-level `[volumes]` settings apply to non-app volume management and do not control declarative app storage.
4 4
5-## Configuration5+## Declarative app volumes
6+
7+Services declare persistent mounts in the app manifest:
6 8
7 ```toml9 ```toml
8-[volumes]10+[[services.web.volume]]
9-auto_create = true11+name = "database-data"
10-prefix = "gordon"12+path = "/var/lib/postgresql/data"
11-preserve = true
12 ```13 ```
13 14
14-## Options15+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.
15-
16-| Option | Type | Default | Description |
17-|--------|------|---------|-------------|
18-| `auto_create` | bool | `true` | Automatically create volumes from Dockerfile VOLUME |
19-| `prefix` | string | `"gordon"` | Prefix for volume names |
20-| `preserve` | bool | `true` | Keep volumes when containers are removed |
21 16
22-## How It Works17+## Administrative bind mounts
23 18
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:
25 20
26-```dockerfile21+```toml
27-FROM postgres:1822+# gordon.toml
28-VOLUME ["/var/lib/postgresql/data"]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
29 ```29 ```
30 30
31-When Gordon deploys this container:
32-
33-1. Reads `VOLUME` directives from image metadata
34-2. Creates named volumes with `prefix-domain-path` naming
35-3. Mounts volumes to the container
36-4. Preserves data across container updates
37-
38-## Volume Naming
39-
40-Volumes are named: `{prefix}-{domain}-{path}`
41-
42-| Domain | Volume Path | Volume Name |
43-|--------|-------------|-------------|
44-| `app.mydomain.com` | `/data` | `gordon-app-mydomain-com-data` |
45-| `db.mydomain.com` | `/var/lib/postgresql/data` | `gordon-db-mydomain-com-var-lib-postgresql-data` |
46-
47-## Persistence
48-
49-### Default: Preserve Volumes
50-
51 ```toml31 ```toml
52-[volumes]32+# app manifest
53-preserve = true33+[[services.web.bind]]
34+name = "app-logs" # must match [app_mounts.<name>]
35+path = "/var/lib/collector/host-logs" # container destination
36+readonly = true
54 ```37 ```
55 38
56-With `preserve = true`: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.
57-- Volumes persist when containers are updated40+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists; there is no wildcard, prefix, or empty-means-all form.
58-- Data survives container restarts41+- 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.
59-- Volumes remain even if container is removed42+- 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.
60 44
61-### Remove with Container45+A read-only log collector is the typical use. Its own state is a named volume; the host logs are exposed through a bind:
62 46
63 ```toml47 ```toml
64-[volumes]48+name = "metrics-agent"
65-preserve = false
66-```
67-
68-With `preserve = false`:
69-- Volumes are removed when containers are removed
70-- Useful for stateless containers
71-- Frees up disk space automatically
72 49
73-## Examples50+[services.collector]
51+image = "registry.example.com/metrics/collector:2.1.0"
74 52
75-### Database Container53+[[services.collector.volume]]
54+name = "collector-state"
55+path = "/var/lib/collector"
76 56
77-```dockerfile57+[[services.collector.bind]]
78-# my-postgres.Dockerfile58+name = "app-logs"
79-FROM postgres:1859+path = "/var/lib/collector/host-logs"
80-VOLUME ["/var/lib/postgresql/data"]60+readonly = true
81-ENV POSTGRES_DB=myapp
82-ENV POSTGRES_USER=app
83 ```61 ```
84 62
85-Gordon automatically:63+Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind.
86-- Creates `gordon-db-mydomain-com-var-lib-postgresql-data` volume
87-- Mounts it to `/var/lib/postgresql/data`
88-- Preserves data across postgres container updates
89 64
90-### Application with Uploads65+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.
91 66
92-```dockerfile67+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.
93-FROM node:18
94-WORKDIR /app
95-VOLUME ["/app/uploads", "/app/data"]
96-COPY . .
97-CMD ["npm", "start"]
98-```
99 68
100-Creates two volumes:69+## Retention
101-- `gordon-app-mydomain-com-app-uploads`
102-- `gordon-app-mydomain-com-app-data`
103 70
104-### Custom Prefix71+Ordinary app operations retain data:
105 72
106-```toml73+- deploy and restart reuse the service's volumes;
107-[volumes]74+- removing a service retains its volumes;
108-prefix = "prod"75+- `gordon apps remove` retains volumes under the removed app's internal UUID;
109-```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.
110 78
111-Volume names become: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.
112-- `prod-app-mydomain-com-data`
113-- `prod-db-mydomain-com-var-lib-postgresql-data`
114 80
115-## Managing Volumes81+## Manual migration
116 82
117-### List Volumes83+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:
118 84
119 ```bash85 ```bash
120-docker volume ls | grep gordon86+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/'
121 ```91 ```
122 92
123-### Inspect Volume93+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:
124 94
125 ```bash95 ```bash
126-docker volume inspect gordon-app-mydomain-com-data96+podman unshare cp -a "<source-path>"/. "<target-path>"/
127 ```97 ```
128 98
129-### Backup Volume99+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.
130 100
131-```bash101+## Pruning
132-docker run --rm \
133- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \
134- -v $(pwd):/backup \
135- alpine tar -czf /backup/db-backup.tar.gz -C /data .
136-```
137 102
138-### Restore Volume103+Use Gordon's ownership-aware command:
139 104
140 ```bash105 ```bash
141-docker run --rm \106+gordon volumes prune --dry-run
142- -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \107+gordon volumes prune
143- -v $(pwd):/backup \
144- alpine tar -xzf /backup/db-backup.tar.gz -C /data
145 ```108 ```
146 109
147-## Volume Cleanup110+A volume is eligible only when all of these requirements hold:
148 111
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.
150 115
151-```bash116+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.
152-# List all gordon volumes
153-docker volume ls -f name=gordon
154 117
155-# Remove specific volume (warning: deletes 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.
156-docker volume rm gordon-old-app-data
157 119
158-# Prune unused volumes (be careful!)120+See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output.
159-docker volume prune
160-```
161 121
162 ## Related122 ## Related
163 123
164-- [Attachments](./attachments.md)124+- [App Manifest](./apps.md)
125+- [Volumes CLI](../cli/volumes.md)
165 - [Configuration Overview](./index.md)126 - [Configuration Overview](./index.md)
M docs/deployment/generic-ci.md +22 -22
@@ -5,10 +5,10 @@ Deploy with Gordon from any CI/CD system.
5 ## Requirements5 ## Requirements
6 6
7 - Docker available on CI runner (for building images)7 - Docker available on CI runner (for building images)
8-- Network access to your Gordon server (HTTPS)8+- Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`
9 - Gordon binary (optional but recommended)9 - Gordon binary (optional but recommended)
10 10
11-## Recommended: gordon push11+## Recommended: gordon images push
12 12
13 ### 1. Generate Deployment Token13 ### 1. Generate Deployment Token
14 14
@@ -17,7 +17,7 @@ On your Gordon server:
17 ```bash17 ```bash
18 gordon auth token generate \18 gordon auth token generate \
19 --subject ci-deploy \19 --subject ci-deploy \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
21 --expiry 021 --expiry 0
22 ```22 ```
23 23
@@ -40,9 +40,9 @@ chmod +x /usr/local/bin/gordon
40 40
41 # 2. Build, push, and deploy41 # 2. Build, push, and deploy
42 export GORDON_TOKEN="$GORDON_TOKEN"42 export GORDON_TOKEN="$GORDON_TOKEN"
43-gordon push --build \43+gordon images push --build \
44 --remote "$GORDON_REMOTE" \44 --remote "$GORDON_REMOTE" \
45- --no-confirm45+
46 ```46 ```
47 47
48 Gordon auto-detects the version from CI environment variables:48 Gordon auto-detects the version from CI environment variables:
@@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables:
52 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |52 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
53 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |53 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
54 | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |54 | 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` |
56 56
57 ## Alternative: docker push57 ## Alternative: docker push
58 58
@@ -69,7 +69,7 @@ docker push gordon.example.com/myapp:v1.0.0
69 ```69 ```
70 70
71 This requires the token subject (`ci-deploy`) as the username.71 This requires the token subject (`ci-deploy`) as the username.
72-Gordon auto-deploys when it receives the image.72+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
73 73
74 ## CI System Examples74 ## CI System Examples
75 75
@@ -89,7 +89,7 @@ pipeline {
89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
90 -o /usr/local/bin/gordon90 -o /usr/local/bin/gordon
91 chmod +x /usr/local/bin/gordon91 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE" --no-confirm92+ gordon images push --build --remote "$GORDON_REMOTE"
93 '''93 '''
94 }94 }
95 }95 }
@@ -119,9 +119,8 @@ jobs:
119 - run:119 - run:
120 name: Deploy120 name: Deploy
121 command: |121 command: |
122- gordon push --build \122+ gordon images push --build \
123- --remote "$GORDON_REMOTE" \123+ --remote "$GORDON_REMOTE"
124- --no-confirm
125 124
126 workflows:125 workflows:
127 deploy:126 deploy:
@@ -153,7 +152,7 @@ steps:
153 - apk add --no-cache curl152 - apk add --no-cache curl
154 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155 - chmod +x /usr/local/bin/gordon154 - chmod +x /usr/local/bin/gordon
156- - gordon push --build --remote "$GORDON_REMOTE" --no-confirm155+ - gordon images push --build --remote "$GORDON_REMOTE"
157 156
158 trigger:157 trigger:
159 event:158 event:
@@ -162,11 +161,13 @@ trigger:
162 161
163 ## Token Scopes Reference162 ## Token Scopes Reference
164 163
164+Use the minimum scopes for the steps the pipeline performs. A repository restriction limits registry access only; it does not narrow `admin:*` permissions.
165+
165 | Workflow | Required Scopes |166 | Workflow | Required Scopes |
166 |----------|----------------|167 |----------|----------------|
167-| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |168+| Build + push, then apply + deploy | `push,pull,admin:apps:read,admin:apps:write` |
168-| Push only (no deploy) | `push,pull,admin:routes:read` |169+| Push only (no deploy) | `push,pull` |
169-| docker push (auto-deploy) | `push,pull` |170+| docker push, then apply + deploy | `push,pull` + `admin:apps:read,admin:apps:write` for the CLI step |
170 171
171 ## Troubleshooting172 ## Troubleshooting
172 173
@@ -174,21 +175,20 @@ trigger:
174 175
175 The token is invalid or has been revoked. Generate a new one.176 The token is invalid or has been revoked. Generate a new one.
176 177
177-### "no route configured for image"178+### "deploy target not found" / app has no desired state
178 179
179-The route must exist before pushing. Create it with:180+Push only stores the image. Declare the app first:
180 181
181 ```bash182 ```bash
182-gordon routes add myapp.example.com myapp183+gordon apps apply --file myapp.toml --remote "$GORDON_REMOTE"
183-# or for first deploy:184+gordon apps deploy myapp --remote "$GORDON_REMOTE"
184-gordon bootstrap myapp.example.com myapp
185 ```185 ```
186 186
187 ### Version shows "latest"187 ### Version shows "latest"
188 188
189 Gordon could not detect a version tag. Either:189 Gordon could not detect a version tag. Either:
190 - Tag your git repo: `git tag v1.0.0 && git push --tags`190 - 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`
192 192
193 ### Large images time out193 ### Large images time out
194 194
@@ -200,4 +200,4 @@ Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take
200 - [GitLab CI](./gitlab-ci.md)200 - [GitLab CI](./gitlab-ci.md)
201 - [Deployment Overview](./index.md)201 - [Deployment Overview](./index.md)
202 - [Authentication](../config/auth.md)202 - [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 @@
1 # GitHub Actions Deployment1 # GitHub Actions Deployment
2 2
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.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7-1. Gordon server running with registry authentication enabled7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8-2. Deployment token generated with the required scopes8+2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps
9 3. GitHub repository secrets configured9 3. GitHub repository secrets configured
10 10
11-## Recommended: gordon push11+## Recommended: gordon images push
12 12
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.
14 14
15 ### 1. Generate Token15 ### 1. Generate Token
16 16
@@ -19,7 +19,7 @@ On your Gordon server:
19 ```bash19 ```bash
20 gordon auth token generate \20 gordon auth token generate \
21 --subject github-actions \21 --subject github-actions \
22- --scopes "push,pull,admin:routes:read,admin:config:write" \22+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
23 --expiry 023 --expiry 0
24 ```24 ```
25 25
@@ -71,9 +71,8 @@ jobs:
71 env:71 env:
72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
73 run: |73 run: |
74- gordon push --build \74+ gordon images push --build \
75- --remote ${{ secrets.GORDON_REMOTE }} \75+ --remote ${{ secrets.GORDON_REMOTE }}
76- --no-confirm
77 ```76 ```
78 77
79 #### Continuous Deploy on Main78 #### Continuous Deploy on Main
@@ -103,10 +102,9 @@ jobs:
103 env:102 env:
104 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}103 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
105 run: |104 run: |
106- gordon push --build \105+ gordon images push --build \
107 --remote ${{ secrets.GORDON_REMOTE }} \106 --remote ${{ secrets.GORDON_REMOTE }} \
108- --tag latest \107+ --tag latest
109- --no-confirm
110 ```108 ```
111 109
112 #### Manual Dispatch110 #### Manual Dispatch
@@ -141,7 +139,7 @@ jobs:
141 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}139 GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142 DEPLOY_TAG: ${{ inputs.tag }}140 DEPLOY_TAG: ${{ inputs.tag }}
143 run: |141 run: |
144- args=(push --build --remote "$GORDON_REMOTE" --no-confirm)142+ args=(push --build --remote "$GORDON_REMOTE")
145 if [[ -n "$DEPLOY_TAG" ]]; then143 if [[ -n "$DEPLOY_TAG" ]]; then
146 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1144 [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
147 args+=(--tag "$DEPLOY_TAG")145 args+=(--tag "$DEPLOY_TAG")
@@ -151,7 +149,7 @@ jobs:
151 149
152 #### Monorepo150 #### Monorepo
153 151
154-Deploy multiple services with separate `gordon push` calls:152+Deploy multiple services with separate `gordon images push` calls:
155 153
156 ```yaml154 ```yaml
157 name: Deploy Services155 name: Deploy Services
@@ -173,21 +171,19 @@ jobs:
173 env:171 env:
174 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}172 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
175 run: |173 run: |
176- gordon push --build \174+ gordon images push --build \
177 --remote ${{ secrets.GORDON_REMOTE }} \175 --remote ${{ secrets.GORDON_REMOTE }} \
178 --file ./services/api/Dockerfile \176 --file ./services/api/Dockerfile \
179- myapp-api \177+ myapp-api
180- --no-confirm
181 178
182 - name: Deploy Web179 - name: Deploy Web
183 env:180 env:
184 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}181 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
185 run: |182 run: |
186- gordon push --build \183+ gordon images push --build \
187 --remote ${{ secrets.GORDON_REMOTE }} \184 --remote ${{ secrets.GORDON_REMOTE }} \
188 --file ./services/web/Dockerfile \185 --file ./services/web/Dockerfile \
189- myapp-web \186+ myapp-web
190- --no-confirm
191 ```187 ```
192 188
193 #### With Build Args189 #### With Build Args
@@ -199,12 +195,12 @@ Pass build arguments to the Docker build:
199 env:195 env:
200 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}196 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
201 run: |197 run: |
202- gordon push --build \198+ gordon images push --build \
203 --remote ${{ secrets.GORDON_REMOTE }} \199 --remote ${{ secrets.GORDON_REMOTE }} \
204 --build-arg NODE_ENV=production \200 --build-arg NODE_ENV=production \
205 --build-arg API_URL=https://api.example.com \201 --build-arg API_URL=https://api.example.com \
206 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \202 --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
207- --no-confirm203+
208 ```204 ```
209 205
210 ### setup-gordon Action Reference206 ### setup-gordon Action Reference
@@ -220,7 +216,7 @@ Pass build arguments to the Docker build:
220 216
221 ## Alternative: Docker-based Workflow217 ## Alternative: Docker-based Workflow
222 218
223-For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed.219+For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Pushing only stores the image — deploy explicitly with `gordon apps deploy` afterwards.
224 220
225 ### Setup221 ### Setup
226 222
@@ -482,5 +478,5 @@ Error: unauthorized: authentication required
482 - [Generic CI](./generic-ci.md)478 - [Generic CI](./generic-ci.md)
483 - [Deployment Overview](./index.md)479 - [Deployment Overview](./index.md)
484 - [Authentication](../config/auth.md)480 - [Authentication](../config/auth.md)
485-- [Push Command](../cli/push.md)481+- [Images Commands](../cli/images.md#gordon-images-push)
486 - [Rollback](./rollback.md)482 - [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.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7-1. Gordon server running with registry authentication enabled7+1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port`
8-2. Generated deployment token8+2. A least-privilege token: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the pipeline applies or deploys apps
9 3. GitLab CI/CD variables configured9 3. GitLab CI/CD variables configured
10 10
11 ## Quick Setup11 ## Quick Setup
@@ -17,7 +17,7 @@ On your Gordon server:
17 ```bash17 ```bash
18 gordon auth token generate \18 gordon auth token generate \
19 --subject gitlab-ci \19 --subject gitlab-ci \
20- --scopes "push,pull,admin:routes:read,admin:config:write" \20+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
21 --expiry 021 --expiry 0
22 ```22 ```
23 23
@@ -45,10 +45,10 @@ deploy:
45 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon45 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
46 - chmod +x /usr/local/bin/gordon46 - chmod +x /usr/local/bin/gordon
47 script:47 script:
48- - gordon push --build48+ - gordon images push --build
49 --remote "$GORDON_REMOTE"49 --remote "$GORDON_REMOTE"
50 --token "$GORDON_TOKEN"50 --token "$GORDON_TOKEN"
51- --no-confirm51+
52 only:52 only:
53 - tags53 - tags
54 ```54 ```
@@ -73,10 +73,10 @@ deploy:
73 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon73 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
74 - chmod +x /usr/local/bin/gordon74 - chmod +x /usr/local/bin/gordon
75 script:75 script:
76- - gordon push --build76+ - gordon images push --build
77 --remote "$GORDON_REMOTE"77 --remote "$GORDON_REMOTE"
78 --token "$GORDON_TOKEN"78 --token "$GORDON_TOKEN"
79- --no-confirm79+
80 rules:80 rules:
81 - if: $CI_COMMIT_TAG81 - if: $CI_COMMIT_TAG
82 ```82 ```
@@ -96,10 +96,10 @@ deploy:
96 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon96 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
97 - chmod +x /usr/local/bin/gordon97 - chmod +x /usr/local/bin/gordon
98 script:98 script:
99- - gordon push --build99+ - gordon images push --build
100 --remote "$GORDON_REMOTE"100 --remote "$GORDON_REMOTE"
101 --token "$GORDON_TOKEN"101 --token "$GORDON_TOKEN"
102- --no-confirm102+
103 rules:103 rules:
104 - if: $CI_COMMIT_BRANCH == "main"104 - if: $CI_COMMIT_BRANCH == "main"
105 ```105 ```
@@ -117,10 +117,10 @@ deploy:
117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118 - chmod +x /usr/local/bin/gordon118 - chmod +x /usr/local/bin/gordon
119 script:119 script:
120- - gordon push --build120+ - gordon images push --build
121 --remote "$GORDON_REMOTE"121 --remote "$GORDON_REMOTE"
122 --token "$GORDON_TOKEN"122 --token "$GORDON_TOKEN"
123- --no-confirm123+
124 when: manual124 when: manual
125 ```125 ```
126 126
@@ -137,12 +137,12 @@ deploy:
137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138 - chmod +x /usr/local/bin/gordon138 - chmod +x /usr/local/bin/gordon
139 script:139 script:
140- - gordon push --build140+ - gordon images push --build
141 --remote "$GORDON_REMOTE"141 --remote "$GORDON_REMOTE"
142 --token "$GORDON_TOKEN"142 --token "$GORDON_TOKEN"
143 --build-arg NODE_ENV=production143 --build-arg NODE_ENV=production
144 --build-arg API_URL=https://api.example.com144 --build-arg API_URL=https://api.example.com
145- --no-confirm145+
146 rules:146 rules:
147 - if: $CI_COMMIT_TAG147 - if: $CI_COMMIT_TAG
148 ```148 ```
@@ -166,7 +166,7 @@ deploy:
166 ```166 ```
167 167
168 This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.168 This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.
169-Gordon auto-deploys when it receives the image.169+Pushing only stores the image; deploy explicitly with `gordon apps deploy` afterwards.
170 170
171 ## Version Detection171 ## Version Detection
172 172
@@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th
208 - [Generic CI](./generic-ci.md)208 - [Generic CI](./generic-ci.md)
209 - [Deployment Overview](./index.md)209 - [Deployment Overview](./index.md)
210 - [Authentication](../config/auth.md)210 - [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 @@
1 # Deployment Overview1 # Deployment Overview
2 2
3-Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure.3+Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests.
4 4
5-## Recommended: gordon push5+## Recommended: gordon images push + apps deploy
6 6
7-`gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines.7+`gordon images push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI.
8 8
9-- Single command: build + push + deploy
10 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token9 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
11 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)10 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
12 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies11 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
13 12
14 ```bash13 ```bash
15-gordon push --build --remote https://gordon.example.com --no-confirm14+gordon 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
16 ```22 ```
17 23
18 ## All Deployment Methods24 ## All Deployment Methods
19 25
20 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |26 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
21 |--------|----------|----------------|-----------------|----------------|27 |--------|----------|----------------|-----------------|----------------|
22-| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |28+| `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
23-| `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) |29+| `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
24-| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
25 30
26-### Method 1: gordon push (Recommended)31+### Method 1: gordon images push + apps deploy (Recommended)
27 32
28-The Gordon CLI handles authentication, image building, and deployment in a single step.33+The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.
29 34
30 - Single token handles everything: admin API access + registry auth via automatic token exchange35 - Single token handles everything: admin API access + registry auth via automatic token exchange
31 - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`36 - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`
32-- Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment)
33 - Requires the Gordon binary on the CI runner37 - Requires the Gordon binary on the CI runner
34 38
35 ```bash39 ```bash
36-# Build, push, and deploy40+# Build and push (OCI transfer only)
37-gordon push --build --remote https://gordon.example.com --no-confirm41+gordon images push --build --remote https://gordon.example.com
38 42
39-# Push only, no deploy43+# Apply the manifest that references the pushed tag, then deploy
40-gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm44+gordon apps apply --file blog.toml --remote https://gordon.example.com
45+gordon apps deploy blog --remote https://gordon.example.com
41 ```46 ```
42 47
43-### Method 2: docker push48+### Method 2: docker push + apps deploy
44 49
45-Standard Docker workflow — no Gordon binary required on the runner.50+Standard Docker workflow — no Gordon binary required on the runner for the push itself.
46 51
47 - Use `docker login`, `docker build`, and `docker push` as usual52 - Use `docker login`, `docker build`, and `docker push` as usual
48-- Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed)53+- Pushing only stores the image; deploy explicitly with the Gordon CLI afterwards
49-- No Gordon binary needed on the CI runner
50 - Registry endpoint is `gordon.example.com` (not a separate registry host)54 - Registry endpoint is `gordon.example.com` (not a separate registry host)
51 55
52 ```bash56 ```bash
53 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com57 echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
54 docker build -t gordon.example.com/myapp:v1.2.0 .58 docker build -t gordon.example.com/myapp:v1.2.0 .
55 docker push gordon.example.com/myapp:v1.2.059 docker push gordon.example.com/myapp:v1.2.0
60+# Then: gordon apps apply --file blog.toml + gordon apps deploy blog
56 ```61 ```
57 62
58-### Method 3: Docker labels + auto-route
59-
60-Add a `gordon.domain` label to your image and Gordon creates the route automatically on push.
61-
62-- Add `gordon.domain=app.example.com` label to your Dockerfile
63-- Push image — Gordon creates or updates the route without manual config
64-- Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml`
65-- Best for GitOps workflows where routes are defined alongside the application
66-
67-```dockerfile
68-LABEL gordon.domain="app.example.com"
69-```
70-
71-See [Auto-Route](../config/auto-route.md) for configuration details.
72-
73 ## Registry Access63 ## Registry Access
74 64
75-Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`.65+Clients use the main Gordon domain over HTTPS, normally on port 443. Gordon binds `server.registry_port` to `127.0.0.1`; the public edge proxies authenticated registry requests to that loopback listener. Do not publish or forward the loopback registry port. With `auth.enabled=false`, registry access is loopback-only and the public edge refuses registry-domain requests.
76 66
77 ### Network Topologies67 ### Network Topologies
78 68
@@ -87,34 +77,27 @@ Gordon's registry is served through the main gordon domain over HTTPS on port 44
87 See the examples below for the right scopes for each workflow.77 See the examples below for the right scopes for each workflow.
88 78
89 ```bash79 ```bash
90-# Minimum scope for gordon push — route lookup + registry push80+# Push only — registry scopes
91-# Server auto-deploys when it receives the image
92 gordon auth token generate \81 gordon auth token generate \
93 --subject ci-bot \82 --subject ci-bot \
94- --scopes "push,pull,admin:routes:read" \83+ --scopes "push,pull" \
95 --expiry 90d84 --expiry 90d
96 85
97-# With explicit CLI-managed deploy (adds config:write for deploy control)86+# Push + apply/deploy — adds app mutation scopes
98 gordon auth token generate \87 gordon auth token generate \
99 --subject ci-bot \88 --subject ci-bot \
100- --scopes "push,pull,admin:routes:read,admin:config:write" \89+ --scopes "push,pull,admin:apps:read,admin:apps:write" \
101 --expiry 90d90 --expiry 90d
102 91
103-# Scoped to a specific repository92+# Repository-scoped registry token for push only
104 gordon auth token generate \93 gordon auth token generate \
105 --subject ci-bot \94 --subject ci-bot \
106 --repo myapp \95 --repo myapp \
107- --scopes "push,pull,admin:routes:read" \
108- --expiry 90d
109-
110-# For docker push — registry scopes only
111-gordon auth token generate \
112- --subject ci-bot \
113 --scopes "push,pull" \96 --scopes "push,pull" \
114 --expiry 90d97 --expiry 90d
115 ```98 ```
116 99
117-Set the generated token as `GORDON_TOKEN` in your CI environment.100+Set the generated token as `GORDON_TOKEN` in your CI environment. `--repo` limits registry push/pull to that repository; it does not constrain `admin:*` scopes. Use a separate least-privilege token for app administration when repository and deployment duties must be isolated.
118 101
119 ## Version Strategies102 ## Version Strategies
120 103
@@ -127,26 +110,23 @@ docker tag myapp gordon.example.com/myapp:latest
127 docker push gordon.example.com/myapp:latest110 docker push gordon.example.com/myapp:latest
128 ```111 ```
129 112
113+Reference it from the app manifest:
114+
130 ```toml115 ```toml
131-[routes]116+[services.web]
132-"app.example.com" = "myapp:latest"117+image = "gordon.example.com/myapp:latest"
133 ```118 ```
134 119
135 ### Semantic Versioning120 ### Semantic Versioning
136 121
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:
138 123
139 ```bash124 ```bash
140 docker tag myapp gordon.example.com/myapp:v2.1.0125 docker tag myapp gordon.example.com/myapp:v2.1.0
141 docker push gordon.example.com/myapp:v2.1.0126 docker push gordon.example.com/myapp:v2.1.0
142 ```127 ```
143 128
144-```toml129+To deploy a new version, update the tag in the app file, apply, and deploy.
145-[routes]
146-"app.example.com" = "myapp:v2.1.0"
147-```
148-
149-To deploy a new version, update the tag in `gordon.toml` and push the new image.
150 130
151 ### Git SHA Tags131 ### Git SHA Tags
152 132
@@ -158,30 +138,22 @@ docker tag myapp gordon.example.com/myapp:$VERSION
158 docker push gordon.example.com/myapp:$VERSION138 docker push gordon.example.com/myapp:$VERSION
159 ```139 ```
160 140
161-## Zero-Downtime Deployment141+## Updates
162-
163-Gordon performs zero-downtime deployments by default:
164 142
165-1. **New container starts** while the old one is still running143+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.
166-2. **Health check** waits for the new container to become ready
167-3. **Traffic switches** to the new container
168-4. **Old container stops** after traffic moves
169 144
170 ```145 ```
171-Timeline ─────────────────────────────────────────>146+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
172-
173-Old Container: [═══════════════════]
174- ↓ stop
175-New Container: [═════════════════════════>
176- ↑ start ↑ traffic routed
177 ```147 ```
178 148
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+
179 ## Related153 ## Related
180 154
181 - [GitHub Actions](./github-actions.md)155 - [GitHub Actions](./github-actions.md)
182 - [GitLab CI](./gitlab-ci.md)156 - [GitLab CI](./gitlab-ci.md)
183 - [Generic CI](./generic-ci.md)157 - [Generic CI](./generic-ci.md)
184-- [Rollback](./rollback.md)158+- [Apps CLI](../cli/apps.md)
185-- [Routes Configuration](../config/routes.md)
186 - [Authentication](../config/auth.md)159 - [Authentication](../config/auth.md)
187-- [Auto-Route](../config/auto-route.md)
M docs/deployment/rollback.md +28 -175
@@ -1,62 +1,28 @@
1 # Rollback1 # Rollback
2 2
3-Roll back to previous versions when deployments fail.3+Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in 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.
4 4
5-## Recommended: Use `gordon pin`5+## Roll Forward to a Previous Tag
6 6
7-`gordon pin` is the built-in rollback and roll-forward workflow for route images:7+The image tags are still in the registry. Point the app file at the last good tag, apply, deploy:
8 8
9 ```bash9 ```bash
10-gordon pin app.example.com10+# 1. Edit blog.toml: image = "gordon.mydomain.com/myapp:v2.0.0"
11-gordon pin app.example.com --tag v2.30.111+gordon apps apply --file blog.toml --remote prod
12-gordon pin list app.example.com12+gordon apps deploy blog --remote prod
13 ```13 ```
14 14
15-Use it when the target image already exists in the Gordon registry and you want Gordon to update the route and redeploy it for you.15+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.
16 16
17-## Rollback Strategies17+## Re-push the Previous Image as latest
18 18
19-### 1. Config-Based Rollback19+When the app file tracks `latest`, re-tag and re-push, then deploy (deploy re-resolves mutable tags):
20-
21-Update the route to point to the previous version:
22-
23-```toml
24-# Current (broken)
25-[routes]
26-"app.mydomain.com" = "myapp:v2.1.0"
27-
28-# Rollback to previous
29-[routes]
30-"app.mydomain.com" = "myapp:v2.0.0"
31-```
32-
33-Then reload:
34-
35-```bash
36-gordon reload
37-```
38-
39-### 2. Push Previous Version
40-
41-Re-push the previous image with the `latest` tag:
42 20
43 ```bash21 ```bash
44-docker pull registry.mydomain.com/myapp:v2.0.022+docker pull gordon.mydomain.com/myapp:v2.0.0
45-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest23+docker tag gordon.mydomain.com/myapp:v2.0.0 gordon.mydomain.com/myapp:latest
46-docker push registry.mydomain.com/myapp:latest24+docker push gordon.mydomain.com/myapp:latest
47-```25+gordon apps deploy blog --remote prod
48-
49-### 3. Manifest-Based Rollback
50-
51-Use OCI manifest annotations for version control:
52-
53-```bash
54-# Rollback to v2.0.0
55-export VERSION=v2.0.0
56-podman manifest create myapp:latest --amend
57-podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
58-podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
59-podman manifest push myapp:latest registry.mydomain.com/myapp:latest
60 ```26 ```
61 27
62 ## Version Management28 ## Version Management
@@ -67,169 +33,56 @@ Always push versioned tags alongside `latest`:
67 33
68 ```bash34 ```bash
69 VERSION=$(git describe --tags)35 VERSION=$(git describe --tags)
70-docker tag myapp registry.mydomain.com/myapp:$VERSION36+docker tag myapp gordon.mydomain.com/myapp:$VERSION
71-docker tag myapp registry.mydomain.com/myapp:latest37+docker tag myapp gordon.mydomain.com/myapp:latest
72-docker push registry.mydomain.com/myapp:$VERSION38+docker push gordon.mydomain.com/myapp:$VERSION
73-docker push registry.mydomain.com/myapp:latest39+docker push gordon.mydomain.com/myapp:latest
74 ```40 ```
75 41
76 ### Semantic Versioning42 ### Semantic Versioning
77 43
78-Use semantic versions for clear rollback targets:44+Use semantic versions for clear recovery targets:
79 45
80 ```46 ```
81 v2.1.0 ← Current (broken)47 v2.1.0 ← Current (broken)
82-v2.0.0 ← Rollback target48+v2.0.0 ← Recovery target
83 v1.9.0 ← Older stable49 v1.9.0 ← Older stable
84 ```50 ```
85 51
86 ### Git SHA Tags52 ### Git SHA Tags
87 53
88-Tag with commit SHA for precise rollbacks:54+Tag with commit SHA for precise recovery:
89 55
90 ```bash56 ```bash
91 # Deploy57 # Deploy
92 SHA=$(git rev-parse --short HEAD)58 SHA=$(git rev-parse --short HEAD)
93-docker push registry.mydomain.com/myapp:$SHA59+docker push gordon.mydomain.com/myapp:$SHA
94-
95-# Rollback to specific commit
96-docker pull registry.mydomain.com/myapp:abc1234
97-docker tag registry.mydomain.com/myapp:abc1234 registry.mydomain.com/myapp:latest
98-docker push registry.mydomain.com/myapp:latest
99-```
100-
101-## Rollback Workflow
102-
103-### Quick Rollback
104-
105-```bash
106-# 1. Identify last working version
107-docker image ls registry.mydomain.com/myapp
108-
109-# 2. Tag as latest
110-docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest
111-
112-# 3. Push
113-docker push registry.mydomain.com/myapp:latest
114-```
115-
116-### Config Rollback
117-
118-```bash
119-# 1. Edit config
120-vim ~/.config/gordon/gordon.toml
121-
122-# 2. Change version
123-# "app.mydomain.com" = "myapp:v2.0.0"
124-
125-# 3. Reload
126-gordon reload
127-```
128-
129-## Automated Rollback
130-
131-### GitHub Actions
132-
133-Add rollback capability to your workflow:
134-
135-```yaml
136-name: Rollback
137-
138-on:
139- workflow_dispatch:
140- inputs:
141- version:
142- description: 'Version to rollback to (e.g., v2.0.0)'
143- required: true
144-
145-jobs:
146- rollback:
147- runs-on: ubuntu-latest
148- steps:
149- - name: Login to Registry
150- env:
151- GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
152- GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }}
153- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
154- run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY"
155-
156- - name: Rollback
157- env:
158- GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
159- VERSION: ${{ inputs.version }}
160- run: |
161- [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162- REPOSITORY="$GORDON_REGISTRY/myapp"
163- SOURCE_IMAGE="$REPOSITORY:$VERSION"
164- LATEST_IMAGE="$REPOSITORY:latest"
165- SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166- IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167- docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168- LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169- [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170- echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171- exit 1
172- }
173-
174- - name: Summary
175- env:
176- VERSION: ${{ inputs.version }}
177- run: |
178- echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
179- printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
180-```
181-
182-### Rollback Script
183-
184-Create a local rollback script:
185-
186-```bash
187-#!/bin/bash
188-# rollback.sh
189-
190-REGISTRY="registry.mydomain.com"
191-IMAGE="myapp"
192-VERSION=$1
193-
194-if [ -z "$VERSION" ]; then
195- echo "Usage: ./rollback.sh <version>"
196- echo "Available versions:"
197- docker image ls "$REGISTRY/$IMAGE" --format "{{.Tag}}"
198- exit 1
199-fi
200-
201-echo "Rolling back to $IMAGE:$VERSION..."
202-docker pull "$REGISTRY/$IMAGE:$VERSION"
203-docker tag "$REGISTRY/$IMAGE:$VERSION" "$REGISTRY/$IMAGE:latest"
204-docker push "$REGISTRY/$IMAGE:latest"
205-echo "Rollback complete!"
206 ```60 ```
207 61
208-## Verifying Rollback62+## Verifying Recovery
209 63
210-After rollback:64+After re-deploying the previous version:
211 65
212 ```bash66 ```bash
213-# Check container is running correct version67+# Check the app's effective vs observed state
214-docker inspect gordon-app-mydomain-com | grep Image68+gordon apps status blog --remote prod
215 69
216 # Check application responds70 # Check application responds
217 curl -I https://app.mydomain.com71 curl -I https://app.mydomain.com
218 72
219 # Check logs73 # Check logs
220-gordon logs -f74+gordon apps logs blog --remote prod
221 ```75 ```
222 76
223 ## Best Practices77 ## Best Practices
224 78
225 1. **Always tag versions** - Don't rely solely on `latest`79 1. **Always tag versions** - Don't rely solely on `latest`
226-2. **Keep N previous versions** - Maintain rollback options80+2. **Keep N previous versions** - Maintain recovery options
227 3. **Test before deploy** - Reduce need for rollbacks81 3. **Test before deploy** - Reduce need for rollbacks
228 4. **Document known-good versions** - Track stable releases82 4. **Document known-good versions** - Track stable releases
229-5. **Automate rollback** - Reduce time to recovery
230 83
231 ## Related84 ## Related
232 85
233 - [Deployment Overview](./index.md)86 - [Deployment Overview](./index.md)
234 - [GitHub Actions](./github-actions.md)87 - [GitHub Actions](./github-actions.md)
235-- [Routes Configuration](../config/routes.md)88+- [Apps CLI](../cli/apps.md)
M docs/getting-started.md +44 -33
@@ -12,7 +12,7 @@ Deploy your first app with Gordon in under 5 minutes.
12 ## 1. Install Gordon12 ## 1. Install Gordon
13 13
14 ```bash14 ```bash
15-curl -fsSL https://gordon.bnema.dev/install | bash15+curl -fsSL https://bnema.dev/gordon/install | bash
16 ```16 ```
17 17
18 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.18 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
79 [entrypoints.edge]79 [entrypoints.edge]
80 address = ":443" # Public smart TCP edge (choose your bind/mapping)80 address = ":443" # Public smart TCP edge (choose your bind/mapping)
81 protocol = "smart_tcp"81 protocol = "smart_tcp"
82-
83-[routes]
84-"app.mydomain.com" = "myapp:latest" # Domain → Image mapping
85 ```82 ```
86 83
84+Application workloads are NOT declared in `gordon.toml`. Each app lives in its own standalone TOML file (see step 8). The old `[routes]`, `[attachments]`, `[network_groups]`, `[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+
87 ## 5. Set Up DNS (Including Wildcard)86 ## 5. Set Up DNS (Including Wildcard)
88 87
89 `gordon_domain` is the single domain used by both the registry and admin API.88 `gordon_domain` is the single domain used by both the registry and admin API.
@@ -96,8 +95,8 @@ In Cloudflare (or your DNS provider), create:
96 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |95 | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
97 96
98 Why wildcard (`*`)?97 Why wildcard (`*`)?
99-- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.98+- It automatically covers app hosts like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
100-- You can add new domains in `[routes]` without creating DNS records one by one.99+- You can add new app HTTP hosts without creating DNS records one by one.
101 100
102 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.101 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
103 102
@@ -129,10 +128,10 @@ sudo loginctl enable-linger $USER
129 128
130 ## 7. Generate a Deploy Token129 ## 7. Generate a Deploy Token
131 130
132-Create a token for remote CLI deploys (skip if auth is disabled):131+Create a token for remote CLI use (skip if auth is disabled):
133 132
134 ```bash133 ```bash
135-gordon auth token generate --subject deploy --scopes push,pull --expiry 0134+gordon auth token generate --subject deploy --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 90d
136 ```135 ```
137 136
138 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.137 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.
@@ -148,53 +147,65 @@ On your local machine:
148 gordon remotes add prod https://gordon.mydomain.com --token <your-token>147 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
149 gordon remotes use prod148 gordon remotes use prod
150 149
151-# Recommended first-time setup150+# Build and push the image (OCI transfer only, never deploys)
152-gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production151+gordon images push myapp --build --remote prod
153-
154-# Then build, push, and deploy
155-gordon push myapp:latest --domain app.example.com --build --no-confirm
156 ```152 ```
157 153
158-What this command does:154+Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.
159 155
160-- `gordon bootstrap` creates or updates the route, applies requested attachments,156+```toml
161- and stores environment variables.157+name = "blog"
162-- This is the recommended path for first deploys because it does not require the158+
163- route to exist ahead of time.159+[services.web]
164-- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.160+image = "gordon.mydomain.com/myapp:latest"
161+
162+[[services.web.http]]
163+host = "app.mydomain.com"
164+port = 3000
165+```
165 166
166-If the route already exists and you only need to push a new image version, use:167+Apply the manifest, then deploy:
167 168
168 ```bash169 ```bash
169-gordon push myapp --build --no-confirm170+gordon apps apply --file blog.toml --remote prod
171+gordon apps deploy blog --remote prod
170 ```172 ```
171 173
172-`gordon push` still requires the route to already exist so it can resolve the174+What these commands do:
173-deploy target.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+```
174 185
175 Your app is now live at `https://app.mydomain.com`!186 Your app is now live at `https://app.mydomain.com`!
176 187
177 ## 9. Update Your App188 ## 9. Update Your App
178 189
179-Push a new image to deploy with zero downtime:190+Push a new image, update the manifest tag if needed, apply, deploy:
180 191
181 ```bash192 ```bash
182-# Make changes, then build + push + deploy193+# Make changes, then build + push
183-gordon push myapp --build --no-confirm194+gordon images push myapp --build --remote prod
195+
196+# Deploy the new tag (re-resolves mutable tags)
197+gordon apps deploy blog --remote prod
184 ```198 ```
185 199
186-Gordon automatically: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.
187-1. Starts the new container
188-2. Waits for it to be ready
189-3. Routes traffic to the new container
190-4. Stops the old container
191 201
192 ## Next Steps202 ## Next Steps
193 203
194 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers204 - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers
195-- [Configuration Reference](./config/index.md) - All configuration options205+- [Configuration Reference](./config/index.md) - All installation configuration options
206+- [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands
196 - [Authentication](./config/auth.md) - Secure your registry207 - [Authentication](./config/auth.md) - Secure your registry
197-- [Environment Variables](./config/env.md) - Configure per-app settings208+- [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass
198 209
199 ## Related210 ## Related
200 211
M docs/index.md +10 -12
@@ -1,15 +1,16 @@
1 # Gordon Documentation1 # Gordon Documentation
2 2
3-Gordon is a self-hosted container deployment platform that combines a private Docker registry with automatic container deployment.3+Gordon is a self-hosted container deployment platform: a private container registry, a declarative app runtime, and a reverse proxy.
4 4
5 ## What is Gordon?5 ## What is Gordon?
6 6
7 Gordon runs on your VPS and provides:7 Gordon runs on your VPS and provides:
8 8
9 - **Private Container Registry** - Push images from your local machine or CI9 - **Private Container Registry** - Push images from your local machine or CI
10-- **HTTP Reverse Proxy** - Routes domains to containers automatically10+- **Declarative Apps** - One TOML file per app, explicit apply then deploy
11-- **Push-to-Deploy** - Containers deploy when you push new images11+- **HTTP Reverse Proxy** - Routes app hosts to containers
12-- **Zero-Downtime Updates** - New containers start before old ones stop12+- **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13+- **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish
13 - **Single Binary** - ~15MB RAM footprint14 - **Single Binary** - ~15MB RAM footprint
14 15
15 ## How It Works16 ## How It Works
@@ -31,7 +32,7 @@ Gordon runs on your VPS and provides:
31 32
32 1. Build your container locally where you have computing power33 1. Build your container locally where you have computing power
33 2. Push to your Gordon registry34 2. Push to your Gordon registry
34-3. Gordon automatically deploys and routes traffic to your container35+3. Apply the app manifest and deploy to activate it
35 36
36 ## Quick Navigation37 ## Quick Navigation
37 38
@@ -46,15 +47,13 @@ Gordon runs on your VPS and provides:
46 47
47 - [Configuration Overview](./config/index.md) - All configuration options48 - [Configuration Overview](./config/index.md) - All configuration options
48 - [Server Settings](./config/server.md) - Ports, domains, and runtime49 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49-- [Routes](./config/routes.md) - Domain to container mapping50+- [App Manifest](./config/apps.md) - Declarative app files (services, hosts, secrets, volumes)
51+- [Migrate to Gordon v3](./migrate-to-v3.md) - Breaking upgrade and explicit secret migration
50 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints52 - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
51 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows53 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
52-- [Secrets](./config/secrets.md) - Secure credential storage54+- [Secrets](./config/secrets.md) - Installation secrets and app secret values
53-- [Network Isolation](./config/network-isolation.md) - Per-app network isolation55+- [Network Isolation](./config/network-isolation.md) - Installation network policy
54-- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
55-- [Attachments](./config/attachments.md) - Service dependencies
56 - [Logging](./config/logging.md) - Log collection and rotation56 - [Logging](./config/logging.md) - Log collection and rotation
57-- [Environment Variables](./config/env.md) - Per-route environment configuration
58 57
59 ### CLI Reference58 ### CLI Reference
60 59
@@ -71,7 +70,6 @@ Gordon runs on your VPS and provides:
71 ### Reference70 ### Reference
72 71
73 - [Docker Labels](./reference/docker-labels.md) - Container and image labels72 - [Docker Labels](./reference/docker-labels.md) - Container and image labels
74-- [Environment Variables](./reference/env-variables.md) - Environment variable syntax
75 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions73 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions
76 74
77 ## Requirements75 ## Requirements
M docs/installation.md +41 -12
@@ -19,7 +19,7 @@ Detailed installation guide for production environments.
19 set -euo pipefail19 set -euo pipefail
20 installer="$(mktemp)"20 installer="$(mktemp)"
21 trap 'rm -f "$installer"' EXIT21 trap 'rm -f "$installer"' EXIT
22- curl -fsSL --output "$installer" https://gordon.bnema.dev/install22+ curl -fsSL --output "$installer" https://bnema.dev/gordon/install
23 if command -v less >/dev/null 2>&1; then23 if command -v less >/dev/null 2>&1; then
24 less "$installer"24 less "$installer"
25 else25 else
@@ -29,7 +29,25 @@ Detailed installation guide for production environments.
29 )29 )
30 ```30 ```
31 31
32-Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.32+Download and inspect the installer before executing it. The installer verifies the release archive checksum, detects Linux/macOS and amd64/arm64, and installs Gordon to `~/.local/bin` without `sudo`. If that directory is absent from `PATH`, an interactive install can offer to add an idempotent marker block to Fish (`~/.config/fish/config.fish`), Bash (`~/.bashrc`), or Zsh (`~/.zshrc`). Restart the shell after accepting, or follow the printed command to update the current shell.
33+
34+For unattended installs, control PATH configuration explicitly:
35+
36+```bash
37+# Update supported shell configuration without prompting
38+curl -fsSL https://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`.
33 51
34 ### Manual Installation52 ### Manual Installation
35 53
@@ -136,6 +154,16 @@ insecure = true
136 EOF154 EOF
137 ```155 ```
138 156
157+Podman assigns loopback backend ports from the host's ephemeral port range when Gordon publishes managed service ports. If an nftables output policy restricts loopback access, allow the Gordon daemon's primary UID—not its subordinate container UIDs—to connect to that range for the protocols used by managed services:
158+
159+```nftables
160+# Confirm the range with: sysctl net.ipv4.ip_local_port_range
161+meta skuid <gordon-uid> ip daddr 127.0.0.1 tcp dport 32768-60999 accept
162+meta skuid <gordon-uid> ip daddr 127.0.0.1 udp dport 32768-60999 accept
163+```
164+
165+Place these rules before any broader loopback rejection. Keep public entrypoint and administrative-port policy separate; these rules only let Gordon reach rootless Podman backends on the local host.
166+
139 ## Firewall Configuration167 ## Firewall Configuration
140 168
141 Gordon needs ports accessible for the registry and the public edge entrypoint.169 Gordon needs ports accessible for the registry and the public edge entrypoint.
@@ -195,11 +223,10 @@ gordon_domain = "gordon.yourdomain.com"
195 [entrypoints.edge]223 [entrypoints.edge]
196 address = ":443"224 address = ":443"
197 protocol = "smart_tcp"225 protocol = "smart_tcp"
198-
199-[routes]
200-"app.yourdomain.com" = "myapp:latest"
201 ```226 ```
202 227
228+Application workloads live in standalone app files, not in `gordon.toml` (see [Getting Started](./getting-started.md#8-deploy-your-first-app)).
229+
203 See [Configuration Reference](./config/index.md) for all options.230 See [Configuration Reference](./config/index.md) for all options.
204 231
205 ## Systemd Service232 ## Systemd Service
@@ -310,17 +337,20 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
310 337
311 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.338 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.
312 339
313-## Installing a Specific Version or Pre-Release340+## Choosing an Install Channel
314 341
315 ```bash342 ```bash
316-# Install an exact version343+# Install an exact release
317-curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash344+curl -fsSL https://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
318 348
319-# Install the latest pre-release instead of the latest stable release349+# Build the current next branch commit from source
320-curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash350+curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh
321 ```351 ```
322 352
323-By default, the installer resolves `latest` to the newest stable release and verifies the downloaded checksum before installing.353+Stable, exact-version, and pre-release installs download release binaries and verify their published checksums. The `next` channel is an **unverified development source build**: it resolves the branch through the GitHub API, pins the resulting commit SHA, downloads that exact source snapshot, and builds it locally for the detected platform. It requires the Go version declared by that commit's `go.mod`, is not covered by release checksums, and may be unstable. Do not combine `GORDON_CHANNEL=next` with `GORDON_VERSION` or `GORDON_PRERELEASE`.
324 354
325 ## Verify Installation355 ## Verify Installation
326 356
@@ -347,7 +377,6 @@ Gordon stores data in the following locations:
347 | `~/.config/gordon/gordon.toml` | Configuration file |377 | `~/.config/gordon/gordon.toml` | Configuration file |
348 | `~/.gordon/` | Default data directory |378 | `~/.gordon/` | Default data directory |
349 | `~/.gordon/registry/` | Container images |379 | `~/.gordon/registry/` | Container images |
350-| `~/.gordon/env/` | Environment files |
351 | `~/.gordon/logs/` | Application logs |380 | `~/.gordon/logs/` | Application logs |
352 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |381 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |
353 382
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.
4 4
5 ## Container Labels5 ## Container Labels
6 6
7-Gordon adds these labels to managed containers:7+Gordon stamps these ownership labels on every container it creates:
8 8
9 | Label | Value | Description |9 | Label | Value | Description |
10 |-------|-------|-------------|10 |-------|-------|-------------|
11 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |11 | `gordon.managed` | `"true"` | Identifies Gordon-managed containers |
12-| `gordon.domain` | Domain name | Domain this container serves |12+| `gordon.app` | App name | App this container serves |
13-| `gordon.image` | Image:tag | Original image from configuration |13+| `gordon.app.service` | Service name | Service this container runs |
14-| `gordon.route` | Domain name | Route this container handles |14+| `gordon.app.revision` | Revision | Active revision that created it |
15 | `gordon.created` | Timestamp | When Gordon created the container |15 | `gordon.created` | Timestamp | When Gordon created the container |
16 16
17-### Propagated Image Labels17+Queries by logical identity use labels, never name parsing. Resources without these labels (old or foreign containers) are preserved, never adopted or deleted.
18-
19-When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing):
20-
21-| Label | Example | Description |
22-|-------|---------|-------------|
23-| `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to |
24-| `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing |
25-
26-These labels only appear on containers whose source image defined them.
27 18
28-### Attachment Labels19+### Legacy Labels
29 20
30-Additional labels for attachment containers:21+Containers created before v3 may carry these labels. They are read-only provenance hints for prune guards — Gordon never infers app state from them:
31 22
32 | Label | Value | Description |23 | Label | Value | Description |
33 |-------|-------|-------------|24 |-------|-------|-------------|
34-| `gordon.attachment` | `"true"` | Container is an attachment service |25+| `gordon.domain` | Domain name | Pre-v3 domain this container served |
35-| `gordon.attached-to` | Domain/group | Route or network group this serves |26+| `gordon.image` | Image:tag | Original image from configuration |
36-27+| `gordon.route` | Domain name | Pre-v3 route this container handled |
37-### Backup Labels
38 28
39-Labels used by the backup subsystem:29+### Backup Metadata
40 30
41-| Label | Value | Description |31+Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[services.<name>.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars.
42-|-------|-------|-------------|
43-| `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container |
44-| `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override |
45-| `gordon.backup.version` | e.g. `"17"` | Explicit database version override |
46-| `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint |
47-| `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers |
48 32
49 ## Image Labels33 ## Image Labels
50 34
51-Labels you can set in your Dockerfile:35+No image-label inference exists in 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.
52-
53-| Label | Example | Description |
54-|-------|---------|-------------|
55-| `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
56-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
57-| `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
58-| `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
59-
60-### Health Check Label
61-
62-When `gordon.health` is set, Gordon performs HTTP GET requests to the specified
63-path during deployment and waits for a 2xx or 3xx response before routing traffic
64-to the new container:
65-
66-```dockerfile
67-FROM node:20-alpine
68-LABEL gordon.health="/api/health"
69-EXPOSE 3000
70-CMD ["node", "server.js"]
71-```
72-
73-Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful
74-response or the `deploy.http_probe_timeout` is reached.
75-
76-### Proxy Port Label
77-
78-When an image exposes multiple ports, Gordon needs to know which one serves HTTP:
79-
80-```dockerfile
81-# Gitea exposes SSH (22) and HTTP (3000)
82-FROM gitea/gitea:latest
83-LABEL gordon.proxy.port=3000
84-EXPOSE 22
85-EXPOSE 3000
86-```
87-
88-Without this label, Gordon uses the first exposed port.
89-
90-**When to use:**
91-- Image exposes multiple ports
92-- First exposed port isn't the HTTP service
93-- You want explicit control over routing
94 36
95 ## Container Naming37 ## Container Naming
96 38
97-Gordon names containers:39+Gordon names containers `gordon-<app>--<service>--<instance>` where `<instance>` is the creating operation ID. Retiring containers keep their instance names until removal.
98-
99-| Pattern | Example |
100-|---------|---------|
101-| `gordon-{domain}` | `gordon-app-mydomain-com` |
102-| `gordon-{domain}-new` | `gordon-app-mydomain-com-new` (during updates) |
103-| `gordon-{domain}-{service}` | `gordon-app-mydomain-com-postgres` (attachments) |
104 40
105 ## Inspecting Labels41 ## Inspecting Labels
106 42
107 View labels on a container:43 View labels on a container:
108 44
109 ```bash45 ```bash
110-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | jq46+docker inspect <container> --format '{{json .Config.Labels}}' | jq
111 ```47 ```
112 48
113 Example output:49 Example output:
114 50
115 ```json51 ```json
116 {52 {
117- "gordon.created": "2024-01-15T10:30:00Z",
118- "gordon.domain": "app.mydomain.com",
119- "gordon.image": "myapp:latest",
120 "gordon.managed": "true",53 "gordon.managed": "true",
121- "gordon.proxy.port": "3000",54+ "gordon.app": "blog",
122- "gordon.route": "app.mydomain.com"55+ "gordon.app.service": "web",
56+ "gordon.app.revision": "rev-3",
57+ "gordon.created": "2024-01-15T10:30:00Z"
123 }58 }
124 ```59 ```
125 60
@@ -131,75 +66,12 @@ Find Gordon-managed containers:
131 # All Gordon containers66 # All Gordon containers
132 docker ps -f "label=gordon.managed=true"67 docker ps -f "label=gordon.managed=true"
133 68
134-# Containers for specific domain69+# Containers for a specific app
135-docker ps -f "label=gordon.domain=app.mydomain.com"70+docker ps -f "label=gordon.app=blog"
136-
137-# Attachment containers
138-docker ps -f "label=gordon.attachment=true"
139-
140-# Attachments for specific route
141-docker ps -f "label=gordon.attached-to=app.mydomain.com"
142-
143-# Backup sidecars
144-docker ps -f "label=gordon.backup.sidecar=true"
145-```
146-
147-## Examples
148-
149-### Standard Application Container
150-
151-```bash
152-docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}'
153-```
154-
155-```json
156-{
157- "gordon.managed": "true",
158- "gordon.domain": "app.mydomain.com",
159- "gordon.image": "myapp:v2.1.0",
160- "gordon.route": "app.mydomain.com",
161- "gordon.created": "2024-01-15T10:30:00Z"
162-}
163-```
164-
165-### Attachment Container
166-
167-```bash
168-docker inspect gordon-app-mydomain-com-postgres --format '{{json .Config.Labels}}'
169 ```71 ```
170 72
171-```json
172-{
173- "gordon.managed": "true",
174- "gordon.attachment": "true",
175- "gordon.attached-to": "app.mydomain.com",
176- "gordon.created": "2024-01-15T10:29:00Z"
177-}
178-```
179-
180-### Multi-Port Dockerfile
181-
182-```dockerfile
183-FROM node:18
184-LABEL gordon.proxy.port=3000
185-
186-WORKDIR /app
187-COPY . .
188-
189-# HTTP server on 3000
190-EXPOSE 3000
191-# Metrics on 9090
192-EXPOSE 9090
193-# WebSocket on 8080
194-EXPOSE 8080
195-
196-CMD ["npm", "start"]
197-```
198-
199-Gordon routes HTTP to port 3000.
200-
201 ## Related73 ## Related
202 74
203 - [Configuration Overview](../config/index.md)75 - [Configuration Overview](../config/index.md)
204-- [Attachments](../config/attachments.md)76+- [App Manifest](../config/apps.md)
205 - [Concepts](../concepts.md)77 - [Concepts](../concepts.md)
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.
5 ## Contents5 ## Contents
6 6
7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
8-- [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
9 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans8 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
10 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions9 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
11 10
M docs/reference/troubleshooting.md +27 -46
@@ -91,7 +91,7 @@ No restart needed — Podman reads this on each pull/push.
91 1. Per-command flag:91 1. Per-command flag:
92 92
93 ```bash93 ```bash
94- gordon push myapp:latest --insecure94+ gordon images push myapp:latest --insecure
95 ```95 ```
96 96
97 2. Environment variable:97 2. Environment variable:
@@ -118,17 +118,13 @@ No restart needed — Podman reads this on each pull/push.
118 118
119 ### "unknown: image not found"119 ### "unknown: image not found"
120 120
121-**Cause:** Image was pushed but no route configured.121+**Cause:** The app manifest references an image that is unavailable to the runtime.
122 122
123-**Solution:** Add route to config:123+**Solution:** Push the image to the configured registry, verify the service's `image` reference in the app manifest, then apply and deploy the accepted revision:
124-```toml
125-[routes]
126-"app.mydomain.com" = "myapp:latest"
127-```
128 124
129-Then reload:
130 ```bash125 ```bash
131-gordon reload126+gordon apps apply --file app.toml
127+gordon apps deploy app
132 ```128 ```
133 129
134 ## Deployment Issues130 ## Deployment Issues
@@ -161,14 +157,14 @@ gordon reload
161 157
162 **Solutions:**158 **Solutions:**
163 159
164-1. Check container logs:160+1. Check workload logs:
165 ```bash161 ```bash
166- docker logs gordon-app-mydomain-com162+ gordon apps logs blog --service web
167 ```163 ```
168 164
169 2. Check Gordon logs:165 2. Check Gordon logs:
170 ```bash166 ```bash
171- gordon logs -f167+ gordon daemon logs -f
172 ```168 ```
173 169
174 3. Run container manually to debug:170 3. Run container manually to debug:
@@ -178,28 +174,23 @@ gordon reload
178 174
179 ### Environment variables not loaded175 ### Environment variables not loaded
180 176
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.
182 178
183 **Solutions:**179 **Solutions:**
184 180
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:
186 ```bash182 ```bash
187- ls ~/.gordon/env/183+ gordon apps apply --file ./blog.toml
188- # Should show: app_mydomain_com.env (dots → underscores)
189 ```184 ```
190 185
191-2. Check file permissions:186+2. Check which secret values are set (names only):
192 ```bash187 ```bash
193- chmod 600 ~/.gordon/env/app_mydomain_com.env188+ gordon apps secrets list blog
194 ```189 ```
195 190
196-3. Check file format (no spaces around `=`):191+3. Deploy or restart: values apply on the next deploy/restart, never to running containers.
197 ```bash192 ```bash
198- # Correct193+ gordon apps restart blog
199- KEY=value
200-
201- # Wrong
202- KEY = value
203 ```194 ```
204 195
205 ### Secrets not resolved196 ### Secrets not resolved
@@ -234,18 +225,14 @@ gordon reload
234 225
235 **Solutions:**226 **Solutions:**
236 227
237-1. Check attachments are configured:228+1. Check that both services declare the same shared network in the app manifest.
238- ```toml
239- [attachments]
240- "app.mydomain.com" = ["postgres:latest"]
241- ```
242 229
243-2. Check containers are in same network:230+2. Check containers are attached to that network:
244 ```bash231 ```bash
245- docker network inspect gordon-app-mydomain-com232+ docker network inspect NETWORK_NAME
246 ```233 ```
247 234
248-3. Use correct hostname (image name before colon):235+3. Use the service name as the internal hostname:
249 ```javascript236 ```javascript
250 // Correct237 // Correct
251 connect("postgresql://postgres:5432/mydb")238 connect("postgresql://postgres:5432/mydb")
@@ -326,7 +313,7 @@ gordon reload
326 313
327 **Solution:** Manual reload:314 **Solution:** Manual reload:
328 ```bash315 ```bash
329-gordon reload316+gordon daemon reload
330 ```317 ```
331 318
332 ### Stale `targets.toml` in config directory319 ### Stale `targets.toml` in config directory
@@ -369,15 +356,9 @@ enabled = true
369 path = "~/.gordon/logs/gordon.log"356 path = "~/.gordon/logs/gordon.log"
370 ```357 ```
371 358
372-### Container logs missing359+### Workload logs unavailable
373-
374-**Cause:** Container log collection disabled.
375 360
376-**Solution:**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.
377-```toml
378-[logging.container_logs]
379-enabled = true # default: true
380-```
381 362
382 ## Diagnostic Commands363 ## Diagnostic Commands
383 364
@@ -386,7 +367,7 @@ enabled = true # default: true
386 systemctl --user status gordon367 systemctl --user status gordon
387 368
388 # View Gordon logs369 # View Gordon logs
389-gordon logs -f370+gordon daemon logs -f
390 journalctl --user -u gordon -f371 journalctl --user -u gordon -f
391 372
392 # List containers373 # List containers
@@ -398,11 +379,11 @@ docker network ls | grep gordon
398 # List volumes379 # List volumes
399 docker volume ls | grep gordon380 docker volume ls | grep gordon
400 381
401-# Check container logs382+# Check workload logs through Gordon
402-docker logs gordon-app-mydomain-com383+gordon apps logs blog --service web
403 384
404-# Inspect container385+# Inspect runtime containers when diagnosing locally
405-docker inspect gordon-app-mydomain-com386+docker ps -f "label=gordon.app=blog"
406 387
407 # Check connectivity388 # Check connectivity
408 curl -v http://localhost:5000/v2/389 curl -v http://localhost:5000/v2/
M docs/upgrading.md +67 -29
@@ -2,11 +2,69 @@
2 2
3 This guide covers breaking changes and migration steps between major versions.3 This guide covers breaking changes and migration steps between major versions.
4 4
5+## 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+
5 ## Route-Domain Validation63 ## Route-Domain Validation
6 64
7 Route keys must be plain hostnames. Use inline tables like `"app.example.com" = { image = "myapp:latest" }`. Gordon still reads legacy `http://...` route entries for backward compatibility and rewrites them on the next save. Update `[routes]`, CLI commands, and automation that reference the old values.65 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.
8 66
9-## Next major: Unified smart TCP entrypoints67+## v2.31.0: Unified smart TCP entrypoints
10 68
11 ### Breaking: `server.port` / `server.tls_port` no longer define public listeners69 ### Breaking: `server.port` / `server.tls_port` no longer define public listeners
12 70
@@ -59,26 +117,6 @@ Keep `server.registry_port` for Docker/Podman push and pull traffic; it is separ
59 - TLS-ALPN-01 is unsupported.117 - TLS-ALPN-01 is unsupported.
60 - Normal HTTPS fallback certificate priority is static certificates, then public ACME certificates, then Gordon's internal CA.118 - Normal HTTPS fallback certificate priority is static certificates, then public ACME certificates, then Gordon's internal CA.
61 119
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-
82 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`120 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`
83 121
84 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`:122 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"
114 gordon_domain = "gordon.example.com"152 gordon_domain = "gordon.example.com"
115 ```153 ```
116 154
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`.
118 156
119 ### Staged Registry Host Rename157 ### Staged Registry Host Rename
120 158
@@ -214,7 +252,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
214 - `gordon auth show-token` prints the stored token for a remote252 - `gordon auth show-token` prints the stored token for a remote
215 - `gordon auth logout` removes the stored token locally253 - `gordon auth logout` removes the stored token locally
216 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls254 - 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 operations255+- Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations
218 256
219 **Migration steps:**257 **Migration steps:**
220 258
@@ -262,7 +300,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
262 5. **Upgrade the binary** and restart:300 5. **Upgrade the binary** and restart:
263 301
264 ```bash302 ```bash
265- curl -fsSL https://gordon.bnema.dev/install | bash303+ curl -fsSL https://bnema.dev/gordon/install | bash
266 systemctl --user restart gordon304 systemctl --user restart gordon
267 ```305 ```
268 306
@@ -294,8 +332,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi
294 # Read-only monitoring332 # Read-only monitoring
295 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d333 gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
296 334
297-# CI deploy with route read + config write335+# CI deploy with app read + write
298-gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0336+gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0
299 ```337 ```
300 338
301 See [Token Scopes](./config/auth.md#token-scopes) for the full list.339 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:
433 3. **Test in staging** if possible471 3. **Test in staging** if possible
434 4. **Upgrade the binary**:472 4. **Upgrade the binary**:
435 ```bash473 ```bash
436- curl -fsSL https://gordon.bnema.dev/install | bash474+ curl -fsSL https://bnema.dev/gordon/install | bash
437 ```475 ```
438 5. **Restart Gordon**:476 5. **Restart Gordon**:
439 ```bash477 ```bash
@@ -441,10 +479,10 @@ If using pass or sops, update your secret paths:
441 ```479 ```
442 6. **Check logs** for any errors:480 6. **Check logs** for any errors:
443 ```bash481 ```bash
444- gordon logs482+ gordon daemon logs
445 ```483 ```
446 484
447 ## Getting Help485 ## Getting Help
448 486
449 - [GitHub Issues](https://github.com/bnema/gordon/issues)487 - [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
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/aws/aws-sdk-go-v2 v1.45.16+ github.com/aws/aws-sdk-go-v2 v1.47.1
7- github.com/aws/aws-sdk-go-v2/config v1.33.17+ github.com/aws/aws-sdk-go-v2/config v1.33.6
8- github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.18+ github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.10
9- github.com/aws/aws-sdk-go-v2/service/s3 v1.109.19+ github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
11 github.com/charmbracelet/bubbles v1.0.011 github.com/charmbracelet/bubbles v1.0.0
12 github.com/charmbracelet/bubbletea v1.3.1012 github.com/charmbracelet/bubbletea v1.3.10
@@ -17,12 +17,12 @@ require (
17 github.com/fsnotify/fsnotify v1.10.1 // direct17 github.com/fsnotify/fsnotify v1.10.1 // direct
18 github.com/go-acme/lego/v4 v4.35.218 github.com/go-acme/lego/v4 v4.35.2
19 github.com/golang-jwt/jwt/v5 v5.3.119 github.com/golang-jwt/jwt/v5 v5.3.1
20- github.com/google/go-containerregistry v0.22.020+ github.com/google/go-containerregistry v0.22.1
21 github.com/google/uuid v1.6.021 github.com/google/uuid v1.6.0
22 github.com/mattn/go-isatty v0.0.2422 github.com/mattn/go-isatty v0.0.24
23- github.com/mattn/go-runewidth v0.0.2823+ github.com/mattn/go-runewidth v0.0.30
24- github.com/moby/moby/api v1.55.024+ github.com/moby/moby/api v1.56.0
25- github.com/moby/moby/client v0.5.125+ github.com/moby/moby/client v0.6.0
26 github.com/muesli/termenv v0.16.026 github.com/muesli/termenv v0.16.0
27 github.com/pelletier/go-toml/v2 v2.4.327 github.com/pelletier/go-toml/v2 v2.4.3
28 github.com/rivo/uniseg v0.4.728 github.com/rivo/uniseg v0.4.7
@@ -32,21 +32,22 @@ require (
32 github.com/spf13/cobra v1.10.232 github.com/spf13/cobra v1.10.2
33 github.com/spf13/viper v1.21.033 github.com/spf13/viper v1.21.0
34 github.com/stretchr/testify v1.12.134 github.com/stretchr/testify v1.12.1
35+ go.etcd.io/bbolt v1.5.0
35 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.036 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
36 go.opentelemetry.io/otel v1.46.037 go.opentelemetry.io/otel v1.46.0
37 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.038 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0
38 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.039 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0
39 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.040 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
41+ go.opentelemetry.io/otel/log v0.22.0
40 go.opentelemetry.io/otel/metric v1.46.042 go.opentelemetry.io/otel/metric v1.46.0
41 go.opentelemetry.io/otel/sdk v1.46.043 go.opentelemetry.io/otel/sdk v1.46.0
42 go.opentelemetry.io/otel/sdk/log v0.22.044 go.opentelemetry.io/otel/sdk/log v0.22.0
43 go.opentelemetry.io/otel/sdk/metric v1.46.045 go.opentelemetry.io/otel/sdk/metric v1.46.0
44 go.opentelemetry.io/otel/trace v1.46.046 go.opentelemetry.io/otel/trace v1.46.0
45 go.uber.org/mock v0.6.0 // direct47 go.uber.org/mock v0.6.0 // direct
46- golang.org/x/mod v0.40.048+ golang.org/x/sync v0.23.0
47- golang.org/x/sync v0.22.049+ golang.org/x/sys v0.48.0
48- golang.org/x/sys v0.47.050+ golang.org/x/time v0.16.0
49- golang.org/x/time v0.15.0
50 gopkg.in/natefinch/lumberjack.v2 v2.2.151 gopkg.in/natefinch/lumberjack.v2 v2.2.1
51 )52 )
52 53
@@ -54,71 +55,69 @@ require (
54 github.com/Microsoft/go-winio v0.6.2 // indirect55 github.com/Microsoft/go-winio v0.6.2 // indirect
55 github.com/atotto/clipboard v0.1.4 // indirect56 github.com/atotto/clipboard v0.1.4 // indirect
56 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect57 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 // indirect58+ github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect
58- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect59+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
59- github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect60+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
60- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect61+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
61- github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect62+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
62 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect63 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 // indirect64+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
64- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect65+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
65- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect66+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
66- github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect67+ github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
67- github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect68+ github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
68- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect69+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
69- github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect70+ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
70- github.com/aws/smithy-go v1.28.1 // indirect71+ github.com/aws/smithy-go v1.28.2 // indirect
71 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect72 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
72 github.com/cenkalti/backoff/v5 v5.0.3 // indirect73 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
73 github.com/cespare/xxhash/v2 v2.3.0 // indirect74 github.com/cespare/xxhash/v2 v2.3.0 // indirect
74 github.com/charmbracelet/colorprofile v0.4.3 // indirect75 github.com/charmbracelet/colorprofile v0.4.3 // indirect
75- github.com/charmbracelet/x/ansi v0.11.6 // indirect76+ github.com/charmbracelet/x/ansi v0.11.8 // indirect
76 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect77 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
77 github.com/charmbracelet/x/term v0.2.2 // indirect78 github.com/charmbracelet/x/term v0.2.2 // indirect
78 github.com/clipperhouse/displaywidth v0.11.0 // indirect79 github.com/clipperhouse/displaywidth v0.11.0 // indirect
79 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect80 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
80 github.com/containerd/errdefs/pkg v0.3.0 // indirect81 github.com/containerd/errdefs/pkg v0.3.0 // indirect
81 github.com/distribution/reference v0.6.0 // indirect82 github.com/distribution/reference v0.6.0 // indirect
82- github.com/docker/cli v29.7.2+incompatible // indirect83+ github.com/docker/cli v29.8.1+incompatible // indirect
83- github.com/docker/docker-credential-helpers v0.9.5 // indirect84+ github.com/docker/docker-credential-helpers v0.9.9 // indirect
84 github.com/docker/go-connections v0.8.1 // indirect85 github.com/docker/go-connections v0.8.1 // indirect
85 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect86 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
86 github.com/felixge/httpsnoop v1.1.0 // indirect87 github.com/felixge/httpsnoop v1.1.0 // indirect
87- github.com/go-jose/go-jose/v4 v4.1.4 // indirect88+ github.com/go-jose/go-jose/v4 v4.1.5 // indirect
88 github.com/go-logr/logr v1.4.4 // indirect89 github.com/go-logr/logr v1.4.4 // indirect
89 github.com/go-logr/stdr v1.2.2 // indirect90 github.com/go-logr/stdr v1.2.2 // indirect
90 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect91 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
91- github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect92+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
92 github.com/inconshreveable/mousetrap v1.1.0 // indirect93 github.com/inconshreveable/mousetrap v1.1.0 // indirect
93- github.com/klauspost/compress v1.19.2 // indirect94+ github.com/klauspost/compress v1.20.1 // indirect
94- github.com/lucasb-eyer/go-colorful v1.4.0 // indirect95+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
95 github.com/mattn/go-colorable v0.1.15 // indirect96 github.com/mattn/go-colorable v0.1.15 // indirect
96 github.com/mattn/go-localereader v0.0.1 // indirect97 github.com/mattn/go-localereader v0.0.1 // indirect
97- github.com/miekg/dns v1.1.72 // indirect98+ github.com/miekg/dns v1.1.73 // indirect
98 github.com/moby/docker-image-spec v1.3.1 // indirect99 github.com/moby/docker-image-spec v1.3.1 // indirect
99 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect100 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
100 github.com/muesli/cancelreader v0.2.2 // indirect101 github.com/muesli/cancelreader v0.2.2 // indirect
101 github.com/opencontainers/go-digest v1.0.0 // indirect102 github.com/opencontainers/go-digest v1.0.0 // indirect
102 github.com/opencontainers/image-spec v1.1.1 // indirect103 github.com/opencontainers/image-spec v1.1.1 // indirect
103 github.com/sagikazarmark/locafero v0.12.0 // indirect104 github.com/sagikazarmark/locafero v0.12.0 // indirect
104- github.com/sirupsen/logrus v1.9.4 // indirect105+ github.com/sirupsen/logrus v1.10.2 // indirect
105 github.com/spf13/cast v1.10.0 // indirect106 github.com/spf13/cast v1.10.0 // indirect
106 github.com/spf13/pflag v1.0.10 // indirect107 github.com/spf13/pflag v1.0.10 // indirect
107 github.com/stretchr/objx v0.5.3 // indirect108 github.com/stretchr/objx v0.5.3 // indirect
108 github.com/subosito/gotenv v1.6.0 // indirect109 github.com/subosito/gotenv v1.6.0 // indirect
109- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect110+ github.com/xo/terminfo v1.2.0 // indirect
110 go.opentelemetry.io/auto/sdk v1.2.1 // indirect111 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
111 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect112 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
112- go.opentelemetry.io/otel/log v0.22.0 // indirect
113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect113 go.opentelemetry.io/proto/otlp v1.11.0 // indirect
114 go.yaml.in/yaml/v3 v3.0.5 // indirect114 go.yaml.in/yaml/v3 v3.0.5 // indirect
115- golang.org/x/crypto v0.55.0 // indirect115+ golang.org/x/crypto v0.57.0 // indirect
116- golang.org/x/net v0.58.0 // indirect116+ golang.org/x/net v0.59.0 // indirect
117- golang.org/x/text v0.41.0 // indirect117+ golang.org/x/text v0.42.0 // indirect
118- golang.org/x/tools v0.49.0 // indirect118+ google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect
119- google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect119+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect
120- google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect120+ google.golang.org/grpc v1.84.0 // indirect
121- google.golang.org/grpc v1.83.1 // indirect
122 google.golang.org/protobuf v1.36.12 // indirect121 google.golang.org/protobuf v1.36.12 // indirect
123 howett.net/plist v1.0.1 // indirect122 howett.net/plist v1.0.1 // indirect
124 )123 )
M go.sum +82 -86 generated file: collapsed
M gordon.toml.example +64 -33
@@ -55,21 +55,15 @@ per_ip_rps = 50
55 burst = 10055 burst = 100
56 trusted_proxies = []56 trusted_proxies = []
57 57
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-
67 [containers]58 [containers]
68 # "compat" preserves broad image compatibility. "strict" enables a read-only59 # "compat" preserves broad image compatibility. "strict" enables a read-only
69 # root filesystem and narrower Linux capabilities.60 # root filesystem and narrower Linux capabilities.
70 security_profile = "compat"61 security_profile = "compat"
71 62
72 [network_isolation]63 [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.
73 enabled = true67 enabled = true
74 network_prefix = "gordon"68 network_prefix = "gordon"
75 # Set true to create Docker internal networks without direct external egress.69 # Set true to create Docker internal networks without direct external egress.
@@ -81,10 +75,10 @@ prefix = "gordon"
81 preserve = true75 preserve = true
82 76
83 [images]77 [images]
84-# Explicit external registries must be listed here. Gordon's own registry is78+# Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed.
85-# always allowed; localhost/private/link-local registries are always rejected.79+# Add other registries as exact hostname+port entries; this does not configure credentials.
86 allowed_registries = []80 allowed_registries = []
87-# Require @sha256:<digest> for allowlisted external registries.81+# Require @sha256:<64 hex chars> for images from every registry, including Gordon.
88 require_digest = false82 require_digest = false
89 83
90 [images.prune]84 [images.prune]
@@ -92,9 +86,6 @@ enabled = false
92 schedule = "daily"86 schedule = "daily"
93 keep_last = 387 keep_last = 3
94 88
95-[auto_route]
96-enabled = false
97-
98 [logging]89 [logging]
99 level = "info"90 level = "info"
100 format = "console"91 format = "console"
@@ -106,13 +97,6 @@ enabled = false
106 # max_backups = 397 # max_backups = 3
107 # max_age = 2898 # max_age = 28
108 99
109-[logging.container_logs]
110-enabled = true
111-# dir = "~/.gordon/logs/containers"
112-# max_size = 100
113-# max_backups = 3
114-# max_age = 28
115-
116 [logging.access_log]100 [logging.access_log]
117 enabled = false101 enabled = false
118 format = "json"102 format = "json"
@@ -124,6 +108,40 @@ max_age = 28
124 exclude_health_checks = true108 exclude_health_checks = true
125 syslog_identifier = "gordon-access"109 syslog_identifier = "gordon-access"
126 110
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+
127 [telemetry]145 [telemetry]
128 enabled = false146 enabled = false
129 # endpoint = "http://localhost:4318"147 # endpoint = "http://localhost:4318"
@@ -133,9 +151,6 @@ metrics = true
133 logs = true151 logs = true
134 trace_sample_rate = 1.0152 trace_sample_rate = 1.0
135 153
136-[env]
137-# dir = "~/.gordon/env"
138-
139 [backups.databases]154 [backups.databases]
140 enabled = false155 enabled = false
141 schedule = "daily"156 schedule = "daily"
@@ -168,14 +183,33 @@ prefix = ""
168 [backups.volumes.retention]183 [backups.volumes.retention]
169 keep = 14184 keep = 14
170 185
171-[routes]186+# [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed.
172 # "app.example.com" = { image = "myapp:latest" }187 # "app.example.com" = { image = "myapp:latest" }
173 188
174 [external_routes]189 [external_routes]
175 # "status.example.com" = "127.0.0.1:9000"190 # "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
176 207
177 # Gordon-managed standalone L4 services. Use service:<name>:<port> from208 # 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.)
179 # [[services]]213 # [[services]]
180 # name = "rust"214 # name = "rust"
181 # image = "registry.example.com:5000/rust:latest"215 # image = "registry.example.com:5000/rust:latest"
@@ -213,9 +247,6 @@ keep = 14
213 # name = "rust-rcon"247 # name = "rust-rcon"
214 # entrypoint = "rcon"248 # entrypoint = "rcon"
215 # service = "service:rust:rcon"249 # service = "service:rust:rcon"
216-250+#
217-[network_groups]251+# REMOVED application sections (see note under [external_routes] above):
218-# "backend" = ["app.example.com", "api.example.com"]252+# [routes], [network_groups], [attachments] were deleted with the cutover.
219-
220-[attachments]
221-# "app.example.com" = ["postgres:18"]
M install.sh +303 -18
@@ -2,12 +2,55 @@
2 set -e2 set -e
3 3
4 # Gordon installer script4 # Gordon installer script
5-# Usage: curl -fsSL https://gordon.bnema.dev/install | bash5+# Usage: curl -fsSL https://bnema.dev/gordon/install | sh
6-# Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash6+# Usage with pre-release: curl -fsSL https://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
7 8
8 REPO="bnema/gordon"9 REPO="bnema/gordon"
9-INSTALL_DIR="/usr/local/bin"
10 VERSION="${GORDON_VERSION:-latest}"10 VERSION="${GORDON_VERSION:-latest}"
11+CHANNEL="${GORDON_CHANNEL:-stable}"
12+
13+case "${GORDON_UPDATE_PATH:-}" in
14+ ''|0|1) ;;
15+ *) echo "Error: GORDON_UPDATE_PATH must be 0 or 1"; exit 1 ;;
16+esac
17+
18+if [ -n "${GORDON_INSTALL_DIR+x}" ]; then
19+ INSTALL_DIR=$GORDON_INSTALL_DIR
20+ if [ -z "$INSTALL_DIR" ]; then
21+ echo "Error: GORDON_INSTALL_DIR must not be empty"
22+ exit 1
23+ fi
24+else
25+ if [ -n "${SUDO_USER:-}" ] || [ "$(id -u)" -eq 0 ]; then
26+ echo "Error: Refusing a default user-local install while running as root or through sudo."
27+ echo "Run the installer as the target user, or set GORDON_INSTALL_DIR explicitly for a global install."
28+ exit 1
29+ fi
30+ if [ -z "${HOME:-}" ]; then
31+ echo "Error: HOME is required for the default installation directory"
32+ exit 1
33+ fi
34+ INSTALL_DIR=$HOME/.local/bin
35+fi
36+
37+case "$INSTALL_DIR" in
38+ /*) ;;
39+ *) echo "Error: GORDON_INSTALL_DIR must be an absolute path"; exit 1 ;;
40+esac
41+if [ "$INSTALL_DIR" != "/" ]; then
42+ while [ "${INSTALL_DIR%/}" != "$INSTALL_DIR" ]; do
43+ INSTALL_DIR=${INSTALL_DIR%/}
44+ done
45+fi
46+INSTALL_DIR_WITHOUT_CONTROLS=$(LC_ALL=C printf '%sX' "$INSTALL_DIR" | LC_ALL=C tr -d '\000-\037\177')
47+if [ "$INSTALL_DIR" = "/" ] || [ "$INSTALL_DIR_WITHOUT_CONTROLS" != "${INSTALL_DIR}X" ]; then
48+ echo "Error: GORDON_INSTALL_DIR contains an unsafe control character or destination"
49+ exit 1
50+fi
51+case "$INSTALL_DIR" in
52+ *:*) echo "Error: GORDON_INSTALL_DIR must not contain ':' because it separates PATH entries"; exit 1 ;;
53+esac
11 54
12 echo "Installing Gordon..."55 echo "Installing Gordon..."
13 56
@@ -43,6 +86,259 @@ esac
43 86
44 echo "Detected: ${OS}/${ARCH}"87 echo "Detected: ${OS}/${ARCH}"
45 88
89+install_binary_atomically() {
90+ binary=$1
91+ destination=$2
92+ destination_dir=${destination%/*}
93+ staging="${destination}.install.$$"
94+
95+ if ! mkdir -p "$destination_dir" 2>/dev/null; then
96+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
97+ echo "Error: Could not create user installation directory ${destination_dir}"
98+ return 1
99+ fi
100+ echo "sudo required to create ${destination_dir}"
101+ sudo mkdir -p "$destination_dir"
102+ fi
103+
104+ if [ -w "$destination_dir" ]; then
105+ trap 'rm -f "$staging"; rm -rf "${TMP_DIR:-}"' EXIT HUP INT TERM
106+ install -m 0755 "$binary" "$staging"
107+ mv -f "$staging" "$destination"
108+ else
109+ if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
110+ echo "Error: User installation directory is not writable: ${destination_dir}"
111+ return 1
112+ fi
113+ echo "sudo required to install to ${destination_dir}"
114+ sudo install -m 0755 "$binary" "$staging"
115+ if ! sudo mv -f "$staging" "$destination"; then
116+ sudo rm -f "$staging"
117+ return 1
118+ fi
119+ fi
120+}
121+
122+path_contains_install_dir() {
123+ case ":${PATH:-}:" in
124+ *:"$INSTALL_DIR":*) return 0 ;;
125+ *) return 1 ;;
126+ esac
127+}
128+
129+single_quote() {
130+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
131+}
132+
133+print_path_instructions() {
134+ quoted_dir=$(single_quote "$INSTALL_DIR")
135+ echo "Add Gordon to the current shell with:"
136+ case "${SHELL##*/}" in
137+ fish) echo " fish_add_path ${quoted_dir}" ;;
138+ *) echo " export PATH=${quoted_dir}:\$PATH" ;;
139+ esac
140+}
141+
142+update_shell_path() {
143+ shell_name=${SHELL##*/}
144+ marker_start="# >>> Gordon installer PATH >>>"
145+ marker_end="# <<< Gordon installer PATH <<<"
146+
147+ case "$shell_name" in
148+ fish)
149+ config_file=$HOME/.config/fish/config.fish
150+ config_dir=${config_file%/*}
151+ command_line="fish_add_path $(single_quote "$INSTALL_DIR")"
152+ ;;
153+ bash)
154+ config_file=$HOME/.bashrc
155+ config_dir=$HOME
156+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
157+ ;;
158+ zsh)
159+ config_file=$HOME/.zshrc
160+ config_dir=$HOME
161+ command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
162+ ;;
163+ *)
164+ echo "Could not update PATH automatically: unsupported or missing SHELL (${SHELL:-unset})."
165+ print_path_instructions
166+ return 0
167+ ;;
168+ esac
169+
170+ if ! mkdir -p "$config_dir" 2>/dev/null ||
171+ { [ -e "$config_file" ] && [ ! -w "$config_file" ]; } ||
172+ { [ ! -e "$config_file" ] && [ ! -w "$config_dir" ]; }; then
173+ echo "Could not update PATH automatically: ${config_file} is not writable."
174+ print_path_instructions
175+ return 0
176+ fi
177+
178+ if [ -f "$config_file" ] && grep -F "$marker_start" "$config_file" >/dev/null 2>&1; then
179+ echo "PATH configuration already exists in ${config_file}."
180+ else
181+ {
182+ printf '\n%s\n' "$marker_start"
183+ printf '%s\n' "$command_line"
184+ printf '%s\n' "$marker_end"
185+ } >>"$config_file"
186+ echo "Added ${INSTALL_DIR} to PATH in ${config_file}."
187+ fi
188+ print_path_instructions
189+}
190+
191+post_install() {
192+ echo ""
193+ echo "Gordon installed successfully at ${INSTALL_DIR}/gordon."
194+
195+ if path_contains_install_dir; then
196+ "$INSTALL_DIR/gordon" version
197+ return
198+ fi
199+
200+ case "${GORDON_UPDATE_PATH:-}" in
201+ 1) update_shell_path ;;
202+ 0) print_path_instructions ;;
203+ '')
204+ if [ -r /dev/tty ] && [ -w /dev/tty ]; then
205+ printf 'Add %s to your PATH configuration? [y/N] ' "$INSTALL_DIR" >/dev/tty
206+ if IFS= read -r answer </dev/tty; then
207+ case "$answer" in
208+ y|Y|yes|YES|Yes) update_shell_path ;;
209+ *) print_path_instructions ;;
210+ esac
211+ else
212+ print_path_instructions
213+ fi
214+ else
215+ print_path_instructions
216+ fi
217+ ;;
218+ esac
219+}
220+
221+version_at_least() {
222+ actual=$1
223+ required=$2
224+ actual_major=${actual%%.*}
225+ actual_minor=${actual#*.}
226+ actual_minor=${actual_minor%%.*}
227+ required_major=${required%%.*}
228+ required_minor=${required#*.}
229+ required_minor=${required_minor%%.*}
230+
231+ [ "$actual_major" -gt "$required_major" ] || {
232+ [ "$actual_major" -eq "$required_major" ] && [ "$actual_minor" -ge "$required_minor" ]
233+ }
234+}
235+
236+install_next() {
237+ if [ -n "${GORDON_VERSION:-}" ]; then
238+ echo "Error: GORDON_VERSION cannot be used with GORDON_CHANNEL=next"
239+ exit 1
240+ fi
241+ if [ -n "${GORDON_PRERELEASE:-}" ]; then
242+ echo "Error: GORDON_PRERELEASE cannot be used with GORDON_CHANNEL=next"
243+ exit 1
244+ fi
245+ if ! command -v go >/dev/null 2>&1; then
246+ echo "Error: GORDON_CHANNEL=next requires Go"
247+ exit 1
248+ fi
249+
250+ echo "WARNING: UNVERIFIED DEVELOPMENT BUILD"
251+ echo "The next channel builds source locally from the current next branch commit."
252+ echo "It is not covered by the signed release checksum path and may be unstable."
253+ echo "Resolving the current next commit..."
254+ COMMIT_DATA=$(curl -fsSL -H "Accept: application/vnd.github+json" \
255+ "https://api.github.com/repos/${REPO}/commits/next" 2>/dev/null || echo "")
256+ COMMIT=$(printf '%s\n' "$COMMIT_DATA" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]*\)".*/\1/p' | head -n 1)
257+ case "$COMMIT" in
258+ *[!0-9a-fA-F]*|'') COMMIT="" ;;
259+ esac
260+ if [ "${#COMMIT}" -ne 40 ]; then
261+ echo "Error: Could not resolve an exact next commit SHA from the GitHub API"
262+ exit 1
263+ fi
264+
265+ TMP_DIR=$(mktemp -d)
266+ trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
267+ SOURCE_TARBALL="$TMP_DIR/source.tar.gz"
268+ SOURCE_URL="https://codeload.github.com/${REPO}/tar.gz/${COMMIT}"
269+ echo "Downloading source pinned to ${COMMIT}..."
270+ if ! curl -fsSL "$SOURCE_URL" -o "$SOURCE_TARBALL"; then
271+ echo "Error: Failed to download source for ${COMMIT}"
272+ exit 1
273+ fi
274+
275+ SOURCE_ROOT="gordon-${COMMIT}"
276+ if ! tar -tzf "$SOURCE_TARBALL" >"$TMP_DIR/archive.list"; then
277+ echo "Error: Invalid source archive"
278+ exit 1
279+ fi
280+ if [ ! -s "$TMP_DIR/archive.list" ] ||
281+ grep -Ev "^${SOURCE_ROOT}(/.*)?$" "$TMP_DIR/archive.list" >/dev/null ||
282+ grep -E '(^|/)\.\.?(/|$)' "$TMP_DIR/archive.list" >/dev/null ||
283+ tar -tvzf "$SOURCE_TARBALL" | grep -Ev '^[-d]' >/dev/null; then
284+ echo "Error: Source archive contains an unsafe path or entry type"
285+ exit 1
286+ fi
287+ tar -xzf "$SOURCE_TARBALL" -C "$TMP_DIR"
288+ SOURCE_DIR="$TMP_DIR/$SOURCE_ROOT"
289+ if [ ! -f "$SOURCE_DIR/go.mod" ] || [ ! -f "$SOURCE_DIR/main.go" ]; then
290+ echo "Error: Source archive is missing go.mod or the root command"
291+ exit 1
292+ fi
293+
294+ REQUIRED_GO=$(awk '$1 == "go" { print $2; exit }' "$SOURCE_DIR/go.mod")
295+ case "$REQUIRED_GO" in
296+ [0-9]*.[0-9]*) ;;
297+ *) echo "Error: Source go.mod has no valid Go version"; exit 1 ;;
298+ esac
299+ INSTALLED_GO=$(go env GOVERSION 2>/dev/null || true)
300+ INSTALLED_GO=${INSTALLED_GO#go}
301+ case "$INSTALLED_GO" in
302+ [0-9]*.[0-9]*) ;;
303+ *) echo "Error: Could not determine installed Go version"; exit 1 ;;
304+ esac
305+ if ! version_at_least "$INSTALLED_GO" "$REQUIRED_GO"; then
306+ echo "Error: Source requires Go ${REQUIRED_GO} or newer; found ${INSTALLED_GO}"
307+ exit 1
308+ fi
309+
310+ BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
311+ NEXT_VERSION="next-${COMMIT}"
312+ BINARY="$TMP_DIR/gordon"
313+ echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."
314+ # 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+
46 # Construct download URL342 # Construct download URL
47 TARBALL="gordon_${OS}_${ARCH}.tar.gz"343 TARBALL="gordon_${OS}_${ARCH}.tar.gz"
48 344
@@ -101,6 +397,8 @@ fi
101 397
102 # Verify checksum398 # Verify checksum
103 echo "Verifying checksum..."399 echo "Verifying checksum..."
400+# The sed expression is intentionally literal; shell expansion would corrupt it.
401+# shellcheck disable=SC2016
104 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')402 TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
105 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)403 CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
106 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')404 CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
@@ -147,19 +445,6 @@ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
147 fi445 fi
148 446
149 echo "Installing to ${INSTALL_DIR}..."447 echo "Installing to ${INSTALL_DIR}..."
150-if [ -w "$INSTALL_DIR" ]; then448+install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
151- install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
152-else
153- echo "sudo required to install to ${INSTALL_DIR}"
154- sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon"
155-fi
156 449
157-# Verify installation450+post_install
158-if command -v gordon >/dev/null 2>&1; then
159- echo ""
160- echo "Gordon installed successfully!"
161- gordon version
162-else
163- echo ""
164- echo "Installation complete. You may need to add ${INSTALL_DIR} to your PATH."
165-fi
A internal/adapters/dto/admin_apps.go +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
2 2
3 import "time"3 import "time"
4 4
5-// BackupJob represents backup metadata in admin API responses.5+// BackupJob represents backup metadata in admin API responses. App is the
6+// canonical backup identity: a domain is never used.
6 type BackupJob struct {7 type BackupJob struct {
7 ID string `json:"id"`8 ID string `json:"id"`
8- Domain string `json:"domain"`9+ App string `json:"app"`
9- DBName string `json:"db_name"`10+ Service string `json:"service,omitempty"`
11+ Database string `json:"database,omitempty"`
10 Schedule string `json:"schedule,omitempty"`12 Schedule string `json:"schedule,omitempty"`
11 Type string `json:"type"`13 Type string `json:"type"`
12 Status string `json:"status"`14 Status string `json:"status"`
@@ -21,9 +23,12 @@ type BackupsResponse struct {
21 Backups []BackupJob `json:"backups"`23 Backups []BackupJob `json:"backups"`
22 }24 }
23 25
24-// BackupRunRequest triggers a backup run.26+// BackupRunRequest triggers one declared database backup. Service and
27+// Database are explicit selectors; an omitted selector only succeeds when
28+// exactly one compatible target exists.
25 type BackupRunRequest struct {29 type BackupRunRequest struct {
26- DB string `json:"db,omitempty"`30+ Service string `json:"service"`
31+ Database string `json:"database,omitempty"`
27 }32 }
28 33
29 // BackupRunResponse is returned after triggering a backup.34 // BackupRunResponse is returned after triggering a backup.
@@ -32,38 +37,23 @@ type BackupRunResponse struct {
32 Backup *BackupJob `json:"backup,omitempty"`37 Backup *BackupJob `json:"backup,omitempty"`
33 }38 }
34 39
35-// DatabaseInfo represents a detected database attachment.40+// VolumeBackupJob represents volume backup metadata in admin API
36-type DatabaseInfo struct {41+// responses. App, service, and the declared volume name are the identity.
37- Type string `json:"type"`
38- Name string `json:"name"`
39- Version string `json:"version,omitempty"`
40- Host string `json:"host"`
41- Port int `json:"port"`
42- ContainerID string `json:"container_id"`
43- ImageName string `json:"image_name"`
44-}
45-
46-// BackupDetectResponse is returned by detect endpoints.
47-type BackupDetectResponse struct {
48- Databases []DatabaseInfo `json:"databases"`
49-}
50-
51-// VolumeBackupJob represents volume backup metadata in admin API responses.
52 type VolumeBackupJob struct {42 type VolumeBackupJob struct {
53- ID string `json:"id"`43+ ID string `json:"id"`
54- Domain string `json:"domain"`44+ App string `json:"app"`
55- ContainerName string `json:"container_name,omitempty"`45+ Service string `json:"service,omitempty"`
56- ContainerID string `json:"container_id,omitempty"`46+ VolumeName string `json:"volume"`
57- VolumeName string `json:"volume_name"`47+ RuntimeVolumeName string `json:"runtime_volume,omitempty"`
58- MountPath string `json:"mount_path,omitempty"`48+ MountPath string `json:"mount_path,omitempty"`
59- Compression string `json:"compression,omitempty"`49+ Compression string `json:"compression,omitempty"`
60- Type string `json:"type"`50+ Type string `json:"type"`
61- Status string `json:"status"`51+ Status string `json:"status"`
62- StartedAt *time.Time `json:"started_at,omitempty"`52+ StartedAt *time.Time `json:"started_at,omitempty"`
63- CompletedAt *time.Time `json:"completed_at,omitempty"`53+ CompletedAt *time.Time `json:"completed_at,omitempty"`
64- SizeBytes int64 `json:"size_bytes"`54+ SizeBytes int64 `json:"size_bytes"`
65- ArtifactRef string `json:"artifact_ref,omitempty"`55+ ArtifactRef string `json:"artifact_ref,omitempty"`
66- Error string `json:"error,omitempty"`56+ Error string `json:"error,omitempty"`
67 }57 }
68 58
69 // VolumeBackupsResponse is returned by volume backup listing endpoints.59 // VolumeBackupsResponse is returned by volume backup listing endpoints.
@@ -71,12 +61,13 @@ type VolumeBackupsResponse struct {
71 Backups []VolumeBackupJob `json:"backups"`61 Backups []VolumeBackupJob `json:"backups"`
72 }62 }
73 63
74-// VolumeBackupRunRequest triggers volume backups.64+// VolumeBackupRunRequest triggers one declared volume backup.
75 type VolumeBackupRunRequest struct {65 type VolumeBackupRunRequest struct {
76- Volume string `json:"volume,omitempty"`66+ Service string `json:"service"`
67+ Volume string `json:"volume,omitempty"`
77 }68 }
78 69
79-// VolumeBackupRunResponse is returned after triggering volume backups.70+// VolumeBackupRunResponse is returned after triggering a volume backup.
80 type VolumeBackupRunResponse struct {71 type VolumeBackupRunResponse struct {
81 Status string `json:"status"`72 Status string `json:"status"`
82 Backups []VolumeBackupJob `json:"backups,omitempty"`73 Backups []VolumeBackupJob `json:"backups,omitempty"`
A internal/adapters/dto/admin_backups_test.go +37 -0
@@ -0,0 +1,37 @@
1+package dto
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestBackupDTOsUseAppIdentity proves the wire shape identifies a backup
12+// by app, service, and database or volume: a domain is never part of it.
13+func TestBackupDTOsUseAppIdentity(t *testing.T) {
14+ job := BackupJob{ID: "job-1", App: "shop", Service: "api", Database: "orders", Schedule: "daily", Status: "completed"}
15+ volume := VolumeBackupJob{ID: "job-2", App: "shop", Service: "api", VolumeName: "data", RuntimeVolumeName: "gordon-shop--api--vol--data", Status: "completed"}
16+
17+ for name, payload := range map[string]any{"database job": job, "volume job": volume} {
18+ t.Run(name, func(t *testing.T) {
19+ raw, err := json.Marshal(payload)
20+ require.NoError(t, err)
21+ var decoded map[string]any
22+ require.NoError(t, json.Unmarshal(raw, &decoded))
23+ for _, key := range []string{"id", "app", "service"} {
24+ assert.Contains(t, decoded, key)
25+ }
26+ assert.NotContains(t, decoded, "domain", "a domain is never a backup identity")
27+ })
28+ }
29+
30+ runRequest, err := json.Marshal(BackupRunRequest{Service: "api", Database: "orders"})
31+ require.NoError(t, err)
32+ assert.JSONEq(t, `{"service":"api","database":"orders"}`, string(runRequest))
33+
34+ volumeRequest, err := json.Marshal(VolumeBackupRunRequest{Service: "api", Volume: "data"})
35+ require.NoError(t, err)
36+ assert.JSONEq(t, `{"service":"api","volume":"data"}`, string(volumeRequest))
37+}
D internal/adapters/dto/admin_bootstrap.go +0 -25
@@ -1,25 +0,0 @@
1-package dto
2-
3-// BootstrapRequest represents a bootstrap operation request.
4-type BootstrapRequest struct {
5- Domain string `json:"domain"`
6- Image string `json:"image"`
7- Attachments []string `json:"attachments,omitempty"`
8- Env map[string]string `json:"env,omitempty"`
9- AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"`
10-}
11-
12-// BootstrapStep represents the result of one bootstrap step.
13-type BootstrapStep struct {
14- Name string `json:"name"`
15- Status string `json:"status"` // "created", "configured", "updated", "noop", "failed"
16-}
17-
18-// BootstrapResponse represents the result of a bootstrap operation.
19-type BootstrapResponse struct {
20- Domain string `json:"domain"`
21- Image string `json:"image"`
22- Steps []BootstrapStep `json:"steps"`
23- Warnings []string `json:"warnings,omitempty"`
24- Next string `json:"next"`
25-}
M internal/adapters/dto/admin_config.go +2 -7
@@ -1,12 +1,12 @@
1 package dto1 package dto
2 2
3 // ConfigResponse represents server configuration.3 // ConfigResponse represents server configuration.
4+// ConfigResponse represents installation configuration. App routes live
5+// under apps show, never here.
4 type ConfigResponse struct {6 type ConfigResponse struct {
5 Server ServerConfig `json:"server"`7 Server ServerConfig `json:"server"`
6- AutoRoute AutoRouteConfig `json:"auto_route"`
7 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`8 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
8 Volumes VolumesConfig `json:"volumes"`9 Volumes VolumesConfig `json:"volumes"`
9- Routes []Route `json:"routes"`
10 ExternalRoutes []ExternalRoute `json:"external_routes"`10 ExternalRoutes []ExternalRoute `json:"external_routes"`
11 }11 }
12 12
@@ -18,11 +18,6 @@ type ServerConfig struct {
18 DataDir string `json:"data_dir,omitempty"`18 DataDir string `json:"data_dir,omitempty"`
19 }19 }
20 20
21-// AutoRouteConfig represents auto-route config details.
22-type AutoRouteConfig struct {
23- Enabled bool `json:"enabled"`
24-}
25-
26 // NetworkIsolationConfig represents network isolation settings.21 // NetworkIsolationConfig represents network isolation settings.
27 type NetworkIsolationConfig struct {22 type NetworkIsolationConfig struct {
28 Enabled bool `json:"enabled"`23 Enabled bool `json:"enabled"`
D internal/adapters/dto/admin_deploy.go +0 -8
@@ -1,8 +0,0 @@
1-package dto
2-
3-// DeployResponse represents a deployment response.
4-type DeployResponse struct {
5- Status string `json:"status"`
6- ContainerID string `json:"container_id"`
7- Domain string `json:"domain"`
8-}
M internal/adapters/dto/admin_images.go +6 -2
@@ -17,11 +17,13 @@ type ImagesResponse struct {
17 Images []Image `json:"images"`17 Images []Image `json:"images"`
18 }18 }
19 19
20-// ImagePruneRequest triggers image pruning.20+// ImagePruneRequest triggers image pruning. DryRun plans the full
21+// operation and deletes nothing.
21 type ImagePruneRequest struct {22 type ImagePruneRequest struct {
22 KeepLast *int `json:"keep_last,omitempty"`23 KeepLast *int `json:"keep_last,omitempty"`
23 PruneDangling *bool `json:"prune_dangling,omitempty"`24 PruneDangling *bool `json:"prune_dangling,omitempty"`
24 PruneRegistry *bool `json:"prune_registry,omitempty"`25 PruneRegistry *bool `json:"prune_registry,omitempty"`
26+ DryRun *bool `json:"dry_run,omitempty"`
25 }27 }
26 28
27 // RuntimePruneResult represents runtime prune results.29 // RuntimePruneResult represents runtime prune results.
@@ -37,8 +39,10 @@ type RegistryPruneResult struct {
37 SpaceReclaimed int64 `json:"space_reclaimed"`39 SpaceReclaimed int64 `json:"space_reclaimed"`
38 }40 }
39 41
40-// ImagePruneResponse is returned by image prune endpoints.42+// ImagePruneResponse is returned by image prune endpoints. Plan is the
43+// same shape for dry runs and executions.
41 type ImagePruneResponse struct {44 type ImagePruneResponse struct {
42 Runtime RuntimePruneResult `json:"runtime"`45 Runtime RuntimePruneResult `json:"runtime"`
43 Registry RegistryPruneResult `json:"registry"`46 Registry RegistryPruneResult `json:"registry"`
47+ Plan PruneSummary `json:"plan"`
44 }48 }
D internal/adapters/dto/admin_restart.go +0 -7
@@ -1,7 +0,0 @@
1-package dto
2-
3-// RestartResponse represents a restart response.
4-type RestartResponse struct {
5- Status string `json:"status"`
6- Domain string `json:"domain"`
7-}
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
2 2
3 // StatusResponse represents admin status information.3 // StatusResponse represents admin status information.
4 type StatusResponse struct {4 type StatusResponse struct {
5- Routes int `json:"routes"`5+ Apps int `json:"apps"`
6 RegistryDomain string `json:"registry_domain"`6 RegistryDomain string `json:"registry_domain"`
7 RegistryPort int `json:"registry_port"`7 RegistryPort int `json:"registry_port"`
8 ServerPort int `json:"server_port"`8 ServerPort int `json:"server_port"`
9- AutoRoute bool `json:"auto_route"`
10 NetworkIsolation bool `json:"network_isolation"`9 NetworkIsolation bool `json:"network_isolation"`
11 ContainerStatuses map[string]string `json:"container_status"`10 ContainerStatuses map[string]string `json:"container_status"`
12 }11 }
A internal/adapters/dto/prune.go +102 -0
@@ -0,0 +1,102 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// PruneCandidate is one planned prune candidate with its verdict and the
6+// stable reason codes behind it.
7+type PruneCandidate struct {
8+ Kind string `json:"kind"`
9+ Ref string `json:"ref"`
10+ Verdict string `json:"verdict"`
11+ Reasons []string `json:"reasons"`
12+}
13+
14+// PruneGap is one inventory that could not be read in full. A gap never
15+// fails the operation; it makes the dependent candidates unknown.
16+type PruneGap struct {
17+ Source string `json:"source"`
18+ Reason string `json:"reason"`
19+ Detail string `json:"detail,omitempty"`
20+}
21+
22+// PruneFailure is one deletion that failed without aborting the run.
23+type PruneFailure struct {
24+ Kind string `json:"kind"`
25+ Ref string `json:"ref"`
26+ Error string `json:"error"`
27+}
28+
29+// PruneSummary is the shared prune report shape: dry-run and execution
30+// return the same structure, distinguished by Applied.
31+type PruneSummary struct {
32+ // Applied is false for a dry run, where nothing was deleted.
33+ Applied bool `json:"applied"`
34+ // Eligible, Protected, and Unknown count the planned candidates.
35+ Eligible int `json:"eligible"`
36+ Protected int `json:"protected"`
37+ Unknown int `json:"unknown"`
38+ // Candidates lists every planned candidate with its verdict, so an
39+ // operator can see why each resource was kept or skipped.
40+ Candidates []PruneCandidate `json:"candidates"`
41+ // Deleted lists exactly the identities that were removed.
42+ Deleted []PruneCandidate `json:"deleted"`
43+ // Failures lists deletions that failed without aborting the run.
44+ Failures []PruneFailure `json:"failures,omitempty"`
45+ // Gaps lists every inventory that could not be read in full.
46+ Gaps []PruneGap `json:"gaps,omitempty"`
47+ // ReclaimedBytes is set only when the adapter can attribute exact
48+ // bytes to the deletions it performed.
49+ ReclaimedBytes int64 `json:"reclaimed_bytes"`
50+ ReclaimedKnown bool `json:"reclaimed_known"`
51+}
52+
53+// CountByKind counts the eligible candidates of one resource kind.
54+func (s PruneSummary) CountByKind(kind domain.PruneResourceKind) int {
55+ count := 0
56+ for _, candidate := range s.Candidates {
57+ if candidate.Kind == string(kind) && candidate.Verdict == string(domain.PruneVerdictEligible) {
58+ count++
59+ }
60+ }
61+ return count
62+}
63+
64+// PruneSummaryFromDomain maps the domain prune report to its wire form.
65+// Dry-run and execution share the shape, distinguished by Applied.
66+func PruneSummaryFromDomain(report domain.PruneReport) PruneSummary {
67+ summary := PruneSummary{
68+ Applied: report.Applied,
69+ Eligible: report.CountByVerdict(domain.PruneVerdictEligible),
70+ Protected: report.CountByVerdict(domain.PruneVerdictProtected),
71+ Unknown: report.CountByVerdict(domain.PruneVerdictUnknown),
72+ ReclaimedBytes: report.ReclaimedBytes,
73+ ReclaimedKnown: report.ReclaimedKnown,
74+ }
75+ for _, candidate := range report.Candidates {
76+ summary.Candidates = append(summary.Candidates, pruneCandidateFromDomain(candidate))
77+ }
78+ for _, candidate := range report.Deleted {
79+ summary.Deleted = append(summary.Deleted, pruneCandidateFromDomain(candidate))
80+ }
81+ for _, failure := range report.Failures {
82+ summary.Failures = append(summary.Failures, PruneFailure{
83+ Kind: string(failure.Kind), Ref: failure.Ref, Error: failure.Err,
84+ })
85+ }
86+ for _, gap := range report.Gaps {
87+ summary.Gaps = append(summary.Gaps, PruneGap{
88+ Source: string(gap.Source), Reason: string(gap.Reason), Detail: gap.Detail,
89+ })
90+ }
91+ return summary
92+}
93+
94+func pruneCandidateFromDomain(candidate domain.PruneCandidateReport) PruneCandidate {
95+ reasons := make([]string, 0, len(candidate.Reasons))
96+ for _, reason := range candidate.Reasons {
97+ reasons = append(reasons, string(reason))
98+ }
99+ return PruneCandidate{
100+ Kind: string(candidate.Kind), Ref: candidate.Ref, Verdict: string(candidate.Verdict), Reasons: reasons,
101+ }
102+}
M internal/adapters/dto/volumes.go +5 -3
@@ -20,8 +20,10 @@ type VolumePruneRequest struct {
20 }20 }
21 21
22 // VolumePruneResponse contains the results of a volume prune operation.22 // VolumePruneResponse contains the results of a volume prune operation.
23+// Plan is the same shape for dry runs and executions.
23 type VolumePruneResponse struct {24 type VolumePruneResponse struct {
24- VolumesRemoved int `json:"volumes_removed"`25+ VolumesRemoved int `json:"volumes_removed"`
25- SpaceReclaimed int64 `json:"space_reclaimed"`26+ SpaceReclaimed int64 `json:"space_reclaimed"`
26- Volumes []Volume `json:"volumes,omitempty"`27+ Volumes []Volume `json:"volumes,omitempty"`
28+ Plan PruneSummary `json:"plan"`
27 }29 }
A internal/adapters/in/appmanifest/parse.go +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 {
60 // newTokenGenerateCmd creates the token generate command.60 // newTokenGenerateCmd creates the token generate command.
61 func newTokenGenerateCmd() *cobra.Command {61 func newTokenGenerateCmd() *cobra.Command {
62 var (62 var (
63- subject string63+ subject string
64- scopes string64+ scopes string
65- expiry string65+ expiry string
66- configPath string66+ cliConfigPath string
67- repo string67+ repo string
68+ rawOut bool
69+ jsonOut bool
68 )70 )
69 71
70 cmd := &cobra.Command{72 cmd := &cobra.Command{
@@ -84,12 +86,13 @@ Registry scopes (for Docker push/pull):
84 Admin scopes (for remote CLI access):86 Admin scopes (for remote CLI access):
85 Format: admin:<resource>:<actions>87 Format: admin:<resource>:<actions>
86 88
87- Resources: routes, secrets, config, status, logs, volumes, * (all)89+ Resources: apps, secrets, config, status, logs, volumes, * (all)
88 Actions: read, write, * (all)90 Actions: read, write, * (all)
89 91
90 Examples:92 Examples:
91 admin:*:* Full admin access (recommended for CLI)93 admin:*:* Full admin access (recommended for CLI)
92- admin:routes:read Read-only routes access94+ admin:apps:read Read-only apps access
95+ admin:apps:write App mutations (apply, deploy, lifecycle)
93 admin:status:read Read-only status access96 admin:status:read Read-only status access
94 admin:logs:read Read-only log access97 admin:logs:read Read-only log access
95 admin:volumes:read Read-only volume access98 admin:volumes:read Read-only volume access
@@ -102,8 +105,8 @@ Repository scoping:
102 Combined examples:105 Combined examples:
103 --scopes "push,pull" All repos, registry only (default)106 --scopes "push,pull" All repos, registry only (default)
104 --scopes "push" --repo myapp Push to myapp only107 --scopes "push" --repo myapp Push to myapp only
105- --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)108+ --scopes "push,pull,admin:apps:read" Minimum CI scope (all repos)
106- --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)109+ --scopes "push,admin:apps:read" --repo app Minimum CI scope (specific repo)
107 110
108 EXPIRY:111 EXPIRY:
109 112
@@ -114,15 +117,18 @@ Supports human-friendly durations:
114 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d117 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
115 Use --expiry=0 for a token that never expires (useful for CI).`,118 Use --expiry=0 for a token that never expires (useful for CI).`,
116 RunE: func(cmd *cobra.Command, args []string) error {119 RunE: func(cmd *cobra.Command, args []string) error {
117- return runTokenGenerate(subject, scopes, expiry, configPath, repo)120+ return runTokenGenerate(subject, scopes, expiry, cliConfigPath, repo, cmd.OutOrStdout(), rawOut, jsonOut)
118 },121 },
119 }122 }
120 123
121 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")124 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
122 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")125 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
123 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")126 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
124- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")127+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
125 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")128 cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
129+ cmd.Flags().BoolVar(&rawOut, "raw", false, "Output only the raw token")
130+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
131+ cmd.MarkFlagsMutuallyExclusive("raw", "json")
126 132
127 _ = cmd.MarkFlagRequired("subject")133 _ = cmd.MarkFlagRequired("subject")
128 134
@@ -132,8 +138,8 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
132 // newTokenListCmd creates the token list command.138 // newTokenListCmd creates the token list command.
133 func newTokenListCmd() *cobra.Command {139 func newTokenListCmd() *cobra.Command {
134 var (140 var (
135- configPath string141+ cliConfigPath string
136- jsonOut bool142+ jsonOut bool
137 )143 )
138 144
139 cmd := &cobra.Command{145 cmd := &cobra.Command{
@@ -141,11 +147,11 @@ func newTokenListCmd() *cobra.Command {
141 Short: "List all authentication tokens",147 Short: "List all authentication tokens",
142 Long: `List all stored authentication tokens with their subjects and expiry information.`,148 Long: `List all stored authentication tokens with their subjects and expiry information.`,
143 RunE: func(cmd *cobra.Command, args []string) error {149 RunE: func(cmd *cobra.Command, args []string) error {
144- return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)150+ return runTokenList(cliConfigPath, cmd.OutOrStdout(), jsonOut)
145 },151 },
146 }152 }
147 153
148- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")154+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
149 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")155 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
150 156
151 return cmd157 return cmd
@@ -154,8 +160,8 @@ func newTokenListCmd() *cobra.Command {
154 // newTokenRevokeCmd creates the token revoke command.160 // newTokenRevokeCmd creates the token revoke command.
155 func newTokenRevokeCmd() *cobra.Command {161 func newTokenRevokeCmd() *cobra.Command {
156 var (162 var (
157- configPath string163+ cliConfigPath string
158- all bool164+ all bool
159 )165 )
160 166
161 cmd := &cobra.Command{167 cmd := &cobra.Command{
@@ -169,16 +175,16 @@ Examples:
169 Args: cobra.MaximumNArgs(1),175 Args: cobra.MaximumNArgs(1),
170 RunE: func(cmd *cobra.Command, args []string) error {176 RunE: func(cmd *cobra.Command, args []string) error {
171 if all {177 if all {
172- return runTokenRevokeAll(configPath)178+ return runTokenRevokeAll(cliConfigPath)
173 }179 }
174 if len(args) == 0 {180 if len(args) == 0 {
175 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")181 return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
176 }182 }
177- return runTokenRevoke(args[0], configPath)183+ return runTokenRevoke(args[0], cliConfigPath)
178 },184 },
179 }185 }
180 186
181- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")187+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
182 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")188 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
183 189
184 return cmd190 return cmd
@@ -188,18 +194,21 @@ Examples:
188 func newAuthInternalCmd() *cobra.Command {194 func newAuthInternalCmd() *cobra.Command {
189 return &cobra.Command{195 return &cobra.Command{
190 Use: "internal",196 Use: "internal",
191- Short: "Show internal registry credentials for manual recovery",197+ Short: "Show server-local registry recovery credentials",
192- Long: `Display the auto-generated internal registry credentials.198+ Long: `Display the server's auto-generated internal registry credentials.
199+
200+This sensitive recovery command is available only when Gordon runs locally on
201+the server. The daemon normally uses these ephemeral credentials automatically
202+to pull from its registry; remote clients and ordinary pushes use scoped tokens.
193 203
194-These credentials are used by Gordon for pulling images from the local registry204+For local recovery only:
195-(localhost:5000) during internal deploys. You can use them for manual recovery:
196 205
197- docker login localhost:5000 -u gordon-internal -p <password>206+ gordon auth internal
207+ docker login localhost:<registry-port> -u gordon-internal --password-stdin
198 208
199-Note: Credentials are regenerated each time Gordon starts, and are only209+Credentials are regenerated whenever the Gordon daemon starts.`,
200-available while Gordon is running.`,
201 RunE: func(cmd *cobra.Command, args []string) error {210 RunE: func(cmd *cobra.Command, args []string) error {
202- return runShowInternalAuth()211+ return runShowInternalAuth(cmd.OutOrStdout())
203 },212 },
204 }213 }
205 }214 }
@@ -402,21 +411,31 @@ func runAuthLogout(out io.Writer) error {
402 }411 }
403 412
404 // runShowInternalAuth displays the internal registry credentials.413 // runShowInternalAuth displays the internal registry credentials.
405-func runShowInternalAuth() error {414+func runShowInternalAuth(out io.Writer) error {
415+ if _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag); err != nil {
416+ return err
417+ } else if isRemote {
418+ return fmt.Errorf("auth internal is server-local only; run it directly on the Gordon server")
419+ }
420+
406 creds, err := app.GetInternalCredentials()421 creds, err := app.GetInternalCredentials()
407 if err != nil {422 if err != nil {
408 return err423 return err
409 }424 }
410 425
411- fmt.Println("Internal Registry Credentials")426+ if err := cliWriteLine(out, "Internal Registry Credentials"); err != nil {
412- fmt.Println("==============================")427+ return err
413- fmt.Printf("Username: %s\n", creds.Username)428+ }
414- fmt.Printf("Password: %s\n", creds.Password)429+ if err := cliWriteLine(out, "=============================="); err != nil {
415- fmt.Println()430+ return err
416- fmt.Println("Usage:")431+ }
417- fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)432+ if err := cliWritef(out, "Username: %s\n", creds.Username); err != nil {
418-433+ return err
419- return nil434+ }
435+ if err := cliWritef(out, "Password: %s\n", creds.Password); err != nil {
436+ return err
437+ }
438+ return cliWriteLine(out, "\nThese credentials are sensitive and intended only for server-local recovery.")
420 }439 }
421 440
422 // runTokenGenerate generates a new authentication token.441 // runTokenGenerate generates a new authentication token.
@@ -463,8 +482,8 @@ func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
463 return scopes, nil482 return scopes, nil
464 }483 }
465 484
466-func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {485+func runTokenGenerate(subject, scopesStr, expiryStr, cliConfigPath, repo string, out io.Writer, rawOut, jsonOut bool) error {
467- cfg, err := loadAuthConfig(configPath)486+ cfg, err := loadAuthConfig(cliConfigPath)
468 if err != nil {487 if err != nil {
469 return err488 return err
470 }489 }
@@ -497,26 +516,34 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
497 return fmt.Errorf("failed to generate token: %w", err)516 return fmt.Errorf("failed to generate token: %w", err)
498 }517 }
499 518
500- fmt.Println("Token generated successfully!")519+ if rawOut {
501- fmt.Printf("Subject: %s\n", subject)520+ return cliWriteLine(out, token)
502- fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))521+ }
503- if expiry == 0 {522+ if jsonOut {
504- fmt.Println("Expiry: never")523+ return writeJSON(out, map[string]any{
505- } else {524+ "subject": subject,
506- fmt.Printf("Expiry: %s\n", expiry)525+ "scopes": scopes,
526+ "expiry": expiry.String(),
527+ "token": token,
528+ })
507 }529 }
508- fmt.Println()
509- fmt.Println("Token (use as password with docker login):")
510- fmt.Println(token)
511- fmt.Println()
512- fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
513 530
514- return nil531+ if err := cliWriteLine(out, "Token generated successfully!"); err != nil {
532+ return err
533+ }
534+ if err := cliWritef(out, "Subject: %s\nScopes: %s\n", subject, strings.Join(scopes, ", ")); err != nil {
535+ return err
536+ }
537+ expiryLabel := expiry.String()
538+ if expiry == 0 {
539+ expiryLabel = "never"
540+ }
541+ return cliWritef(out, "Expiry: %s\n\nToken (use as password with docker login):\n%s\n\nUsage: docker login -u %s -p <token> <registry>\n", expiryLabel, token, subject)
515 }542 }
516 543
517 // runTokenList lists all stored tokens.544 // runTokenList lists all stored tokens.
518-func runTokenList(configPath string, out io.Writer, jsonOut bool) error {545+func runTokenList(cliConfigPath string, out io.Writer, jsonOut bool) error {
519- cfg, err := loadAuthConfig(configPath)546+ cfg, err := loadAuthConfig(cliConfigPath)
520 if err != nil {547 if err != nil {
521 return err548 return err
522 }549 }
@@ -579,8 +606,8 @@ func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
579 }606 }
580 607
581 // runTokenRevoke revokes a token by ID.608 // runTokenRevoke revokes a token by ID.
582-func runTokenRevoke(tokenID, configPath string) error {609+func runTokenRevoke(tokenID, cliConfigPath string) error {
583- cfg, err := loadAuthConfig(configPath)610+ cfg, err := loadAuthConfig(cliConfigPath)
584 if err != nil {611 if err != nil {
585 return err612 return err
586 }613 }
@@ -601,8 +628,8 @@ func runTokenRevoke(tokenID, configPath string) error {
601 }628 }
602 629
603 // runTokenRevokeAll revokes all tokens.630 // runTokenRevokeAll revokes all tokens.
604-func runTokenRevokeAll(configPath string) error {631+func runTokenRevokeAll(cliConfigPath string) error {
605- cfg, err := loadAuthConfig(configPath)632+ cfg, err := loadAuthConfig(cliConfigPath)
606 if err != nil {633 if err != nil {
607 return err634 return err
608 }635 }
@@ -635,14 +662,14 @@ type cliConfig struct {
635 }662 }
636 663
637 // loadAuthConfig loads the configuration needed for auth CLI commands.664 // loadAuthConfig loads the configuration needed for auth CLI commands.
638-func loadAuthConfig(configPath string) (*cliConfig, error) {665+func loadAuthConfig(cliConfigPath string) (*cliConfig, error) {
639 v := viper.New()666 v := viper.New()
640 667
641 // Set defaults668 // Set defaults
642 v.SetDefault("server.data_dir", app.DefaultDataDir())669 v.SetDefault("server.data_dir", app.DefaultDataDir())
643 v.SetDefault("auth.secrets_backend", "unsafe")670 v.SetDefault("auth.secrets_backend", "unsafe")
644 671
645- app.ConfigureViper(v, configPath)672+ app.ConfigureViper(v, cliConfigPath)
646 673
647 if err := v.ReadInConfig(); err != nil {674 if err := v.ReadInConfig(); err != nil {
648 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {675 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
M internal/adapters/in/cli/auth_scopes_test.go +2 -2
@@ -26,9 +26,9 @@ func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
26 }26 }
27 27
28 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {28 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
29- got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")29+ got, err := parseAndConvertScopes("push,pull,admin:apps:read", "myapp")
30 require.NoError(t, err)30 require.NoError(t, err)
31- assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)31+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:apps:read"}, got)
32 }32 }
33 33
34 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {34 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
D internal/adapters/in/cli/autoroute_allow.go +0 -112
@@ -1,112 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7-
8- "github.com/spf13/cobra"
9-)
10-
11-func newAutorouteCmd() *cobra.Command {
12- cmd := &cobra.Command{
13- Use: "autoroute",
14- Short: "Manage auto-route settings",
15- }
16- cmd.AddCommand(newAutorouteAllowCmd())
17- return cmd
18-}
19-
20-func newAutorouteAllowCmd() *cobra.Command {
21- cmd := &cobra.Command{
22- Use: "allow",
23- Short: "Manage auto-route allowed domains",
24- }
25- cmd.AddCommand(newAutorouteAllowAddCmd())
26- cmd.AddCommand(newAutorouteAllowListCmd())
27- cmd.AddCommand(newAutorouteAllowRemoveCmd())
28- return cmd
29-}
30-
31-func newAutorouteAllowAddCmd() *cobra.Command {
32- return &cobra.Command{
33- Use: "add <pattern>",
34- Short: "Allow an auto-route domain pattern",
35- Args: cobra.ExactArgs(1),
36- RunE: func(cmd *cobra.Command, args []string) error {
37- return runAutorouteAllowAdd(cmd.Context(), args, cmd.OutOrStdout())
38- },
39- }
40-}
41-
42-func newAutorouteAllowListCmd() *cobra.Command {
43- var jsonOut bool
44- cmd := &cobra.Command{
45- Use: "list",
46- Short: "List auto-route allowed domains",
47- RunE: func(cmd *cobra.Command, args []string) error {
48- return runAutorouteAllowList(cmd.Context(), cmd.OutOrStdout(), jsonOut)
49- },
50- }
51- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
52- return cmd
53-}
54-
55-func newAutorouteAllowRemoveCmd() *cobra.Command {
56- return &cobra.Command{
57- Use: "remove <pattern>",
58- Short: "Remove an auto-route domain pattern",
59- Args: cobra.ExactArgs(1),
60- RunE: func(cmd *cobra.Command, args []string) error {
61- return runAutorouteAllowRemove(cmd.Context(), args, cmd.OutOrStdout())
62- },
63- }
64-}
65-
66-func runAutorouteAllowAdd(ctx context.Context, args []string, out io.Writer) error {
67- handle, err := resolveControlPlane(configPath)
68- if err != nil {
69- return err
70- }
71- defer handle.close()
72- if err := handle.plane.AddAutoRouteAllowedDomain(ctx, args[0]); err != nil {
73- return fmt.Errorf("failed to add auto-route allowed domain: %w", err)
74- }
75- return cliWriteLine(out, cliRenderSuccess("Allowed domain added"))
76-}
77-
78-func runAutorouteAllowList(ctx context.Context, out io.Writer, jsonOut bool) error {
79- handle, err := resolveControlPlane(configPath)
80- if err != nil {
81- return err
82- }
83- defer handle.close()
84- domains, err := handle.plane.GetAutoRouteAllowedDomains(ctx)
85- if err != nil {
86- return fmt.Errorf("failed to list auto-route allowed domains: %w", err)
87- }
88- if jsonOut {
89- return writeJSON(out, map[string]any{"domains": domains})
90- }
91- if len(domains) == 0 {
92- return cliWriteLine(out, cliRenderMuted("No allowed domains configured"))
93- }
94- for _, domain := range domains {
95- if err := cliWriteLine(out, domain); err != nil {
96- return err
97- }
98- }
99- return nil
100-}
101-
102-func runAutorouteAllowRemove(ctx context.Context, args []string, out io.Writer) error {
103- handle, err := resolveControlPlane(configPath)
104- if err != nil {
105- return err
106- }
107- defer handle.close()
108- if err := handle.plane.RemoveAutoRouteAllowedDomain(ctx, args[0]); err != nil {
109- return fmt.Errorf("failed to remove auto-route allowed domain: %w", err)
110- }
111- return cliWriteLine(out, cliRenderSuccess("Allowed domain removed"))
112-}
M internal/adapters/in/cli/backup.go +157 -230
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7- "os"
8 "text/tabwriter"7 "text/tabwriter"
9 "time"8 "time"
10 9
@@ -14,54 +13,35 @@ import (
14 "github.com/bnema/gordon/pkg/bytesize"13 "github.com/bnema/gordon/pkg/bytesize"
15 )14 )
16 15
17-var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {16+// newBackupCmd creates the backup command group. Backups are identified by
18- if domainName != "" {17+// app, service, and the declared database or volume: a domain is never an
19- return resolveControlPlaneForRouteDomain(ctx, domainName)18+// identity.
20- }
21- return resolveControlPlane(configPath)
22-}
23-
24-// newBackupCmd creates the backup command group.
25 func newBackupCmd() *cobra.Command {19 func newBackupCmd() *cobra.Command {
26 cmd := &cobra.Command{20 cmd := &cobra.Command{
27 Use: "backups",21 Use: "backups",
28 Aliases: []string{"backup"},22 Aliases: []string{"backup"},
29- Short: "Manage backups",23+ Short: "Manage app backups",
30- Long: `Manage backups.24+ Long: `Manage app backups.
31 25
32-Runs locally via in-process services by default, or against a remote Gordon26+Backup targets come from the app manifest: a service declares its databases
33-instance when --remote targeting is configured.`,27+and volumes, and which of them are backed up. Runs locally through the daemon's
28+authenticated Unix socket, or against a remote Gordon instance when --remote
29+targeting is configured.`,
34 }30 }
35 31
36- cmd.AddCommand(newBackupDatabasesCmd())
37- cmd.AddCommand(newBackupVolumesCmd())
38-
39- // Compatibility aliases for the original database backup command shape.
40 cmd.AddCommand(newBackupListCmd())32 cmd.AddCommand(newBackupListCmd())
41 cmd.AddCommand(newBackupRunCmd())33 cmd.AddCommand(newBackupRunCmd())
42- cmd.AddCommand(newBackupDetectCmd())
43 cmd.AddCommand(newBackupStatusCmd())34 cmd.AddCommand(newBackupStatusCmd())
35+ cmd.AddCommand(newBackupVolumeCmd())
44 36
45 return cmd37 return cmd
46 }38 }
47 39
48-func newBackupDatabasesCmd() *cobra.Command {40+// newBackupVolumeCmd creates the `backup volume` group.
49- cmd := &cobra.Command{41+func newBackupVolumeCmd() *cobra.Command {
50- Use: "databases",
51- Aliases: []string{"database", "db"},
52- Short: "Manage database backups",
53- }
54- cmd.AddCommand(newBackupListCmd())
55- cmd.AddCommand(newBackupRunCmd())
56- cmd.AddCommand(newBackupDetectCmd())
57- cmd.AddCommand(newBackupStatusCmd())
58- return cmd
59-}
60-
61-func newBackupVolumesCmd() *cobra.Command {
62 cmd := &cobra.Command{42 cmd := &cobra.Command{
63- Use: "volumes",43+ Use: "volume",
64- Short: "Manage volume backups",44+ Short: "Manage app volume backups",
65 }45 }
66 cmd.AddCommand(newVolumeBackupListCmd())46 cmd.AddCommand(newVolumeBackupListCmd())
67 cmd.AddCommand(newVolumeBackupRunCmd())47 cmd.AddCommand(newVolumeBackupRunCmd())
@@ -73,44 +53,36 @@ func newBackupListCmd() *cobra.Command {
73 var jsonOut bool53 var jsonOut bool
74 54
75 cmd := &cobra.Command{55 cmd := &cobra.Command{
76- Use: "list [domain]",56+ Use: "list [APP]",
77- Short: "List backups",57+ Short: "List database backups",
78- Long: cliRenderMuted("List backups for all domains or a specific domain."),58+ Long: cliRenderMuted("List stored backups for all apps, or for one app."),
79 Args: cobra.MaximumNArgs(1),59 Args: cobra.MaximumNArgs(1),
80 RunE: func(cmd *cobra.Command, args []string) error {60 RunE: func(cmd *cobra.Command, args []string) error {
81- domainName := ""61+ app := ""
82 if len(args) == 1 {62 if len(args) == 1 {
83- domainName = args[0]63+ app = args[0]
84- }
85-
86- var (
87- handle *controlPlaneHandle
88- err error
89- )
90- if domainName != "" {
91- handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName)
92- } else {
93- handle, err = resolveControlPlane(configPath)
94 }64 }
65+ handle, err := resolveControlPlane(cliConfigPath)
95 if err != nil {66 if err != nil {
96 return err67 return err
97 }68 }
98 defer handle.close()69 defer handle.close()
99-70+ return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
100- jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
101- if err != nil {
102- return fmt.Errorf("failed to list backups: %w", err)
103- }
104-
105- return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
106 },71 },
107 }72 }
108 73
109 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")74 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
110-
111 return cmd75 return cmd
112 }76 }
113 77
78+func runBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
79+ jobs, err := plane.ListBackups(ctx, app)
80+ if err != nil {
81+ return fmt.Errorf("failed to list backups: %w", err)
82+ }
83+ return printBackupJobs(out, jobs, jsonOut)
84+}
85+
114 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {86 func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
115 if len(jobs) == 0 {87 if len(jobs) == 0 {
116 if jsonOut {88 if jsonOut {
@@ -127,106 +99,192 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
127 return err99 return err
128 }100 }
129 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)101 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
130- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {102+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tDATABASE\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
131 return err103 return err
132 }104 }
133-
134 for _, job := range jobs {105 for _, job := range jobs {
135- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {106+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
107+ job.App, job.Service, job.Database, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
136 return err108 return err
137 }109 }
138 }110 }
139 return w.Flush()111 return w.Flush()
140 }112 }
141 113
142-func newVolumeBackupListCmd() *cobra.Command {114+func newBackupRunCmd() *cobra.Command {
115+ var service string
116+ var database string
143 var jsonOut bool117 var jsonOut bool
144 118
145 cmd := &cobra.Command{119 cmd := &cobra.Command{
146- Use: "list [domain]",120+ Use: "run APP",
147- Short: "List volume backups",121+ Short: "Run a database backup now",
148- Args: cobra.MaximumNArgs(1),122+ Long: cliRenderMuted(`Run the declared database backup of one app service.
123+
124+--service and --database select the target. Omitting a selector is only
125+allowed when exactly one compatible target exists.`),
126+ Args: cobra.ExactArgs(1),
149 RunE: func(cmd *cobra.Command, args []string) error {127 RunE: func(cmd *cobra.Command, args []string) error {
150- domainName := ""128+ handle, err := resolveControlPlane(cliConfigPath)
151- if len(args) == 1 {
152- domainName = args[0]
153- }
154- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
155 if err != nil {129 if err != nil {
156 return err130 return err
157 }131 }
158 defer handle.close()132 defer handle.close()
133+ return runBackupRun(cmd.Context(), handle.plane, args[0], service, database, cmd.OutOrStdout(), jsonOut)
134+ },
135+ }
136+
137+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the database")
138+ cmd.Flags().StringVar(&database, "database", "", "Declared database name (optional)")
139+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
140+ return cmd
141+}
142+
143+func runBackupRun(ctx context.Context, plane ControlPlane, app, service, database string, out io.Writer, jsonOut bool) error {
144+ result, err := plane.RunBackup(ctx, app, service, database)
145+ if err != nil {
146+ return fmt.Errorf("failed to run backup: %w", err)
147+ }
148+ if result.Backup == nil {
149+ return fmt.Errorf("backup run completed without a backup payload")
150+ }
151+ if jsonOut {
152+ return writeJSON(out, result)
153+ }
154+ return printBackupJobs(out, []dto.BackupJob{*result.Backup}, false)
155+}
159 156
160- jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)157+func newBackupStatusCmd() *cobra.Command {
158+ var jsonOut bool
159+ cmd := &cobra.Command{
160+ Use: "status",
161+ Short: "Show database backup status",
162+ Long: cliRenderMuted("Show stored backups plus declared targets that have no completed backup yet."),
163+ RunE: func(cmd *cobra.Command, args []string) error {
164+ handle, err := resolveControlPlane(cliConfigPath)
161 if err != nil {165 if err != nil {
162- return fmt.Errorf("failed to list volume backups: %w", err)166+ return err
163 }167 }
164- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)168+ defer handle.close()
169+ return runBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
165 },170 },
166 }171 }
167 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")172 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168 return cmd173 return cmd
169 }174 }
170 175
171-func newVolumeBackupRunCmd() *cobra.Command {176+func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
172- var volumeName string177+ jobs, err := plane.BackupStatus(ctx)
178+ if err != nil {
179+ return fmt.Errorf("failed to get backup status: %w", err)
180+ }
181+ return printBackupJobs(out, jobs, jsonOut)
182+}
183+
184+func newVolumeBackupListCmd() *cobra.Command {
173 var jsonOut bool185 var jsonOut bool
174 186
175 cmd := &cobra.Command{187 cmd := &cobra.Command{
176- Use: "run [domain]",188+ Use: "list [APP]",
177- Short: "Run volume backups now",189+ Short: "List volume backups",
178 Args: cobra.MaximumNArgs(1),190 Args: cobra.MaximumNArgs(1),
179 RunE: func(cmd *cobra.Command, args []string) error {191 RunE: func(cmd *cobra.Command, args []string) error {
180- domainName := ""192+ app := ""
181 if len(args) == 1 {193 if len(args) == 1 {
182- domainName = args[0]194+ app = args[0]
183 }195 }
184- handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)196+ handle, err := resolveControlPlane(cliConfigPath)
185 if err != nil {197 if err != nil {
186 return err198 return err
187 }199 }
188 defer handle.close()200 defer handle.close()
201+ return runVolumeBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
202+ },
203+ }
204+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
205+ return cmd
206+}
189 207
190- result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)208+func runVolumeBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
209+ jobs, err := plane.ListVolumeBackups(ctx, app)
210+ if err != nil {
211+ return fmt.Errorf("failed to list volume backups: %w", err)
212+ }
213+ return printVolumeBackupJobs(out, jobs, jsonOut)
214+}
215+
216+func newVolumeBackupRunCmd() *cobra.Command {
217+ var service string
218+ var volume string
219+ var jsonOut bool
220+
221+ cmd := &cobra.Command{
222+ Use: "run APP",
223+ Short: "Run a volume backup now",
224+ Long: cliRenderMuted(`Run the declared volume backup of one app service.
225+
226+--service and --volume select the target. Omitting a selector is only
227+allowed when exactly one compatible target exists.`),
228+ Args: cobra.ExactArgs(1),
229+ RunE: func(cmd *cobra.Command, args []string) error {
230+ handle, err := resolveControlPlane(cliConfigPath)
191 if err != nil {231 if err != nil {
192- if result != nil && len(result.Backups) > 0 {232+ return err
193- if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
194- return printErr
195- }
196- }
197- return fmt.Errorf("failed to run volume backups: %w", err)
198 }233 }
199- return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)234+ defer handle.close()
235+ return runVolumeBackupRun(cmd.Context(), handle.plane, args[0], service, volume, cmd.OutOrStdout(), jsonOut)
200 },236 },
201 }237 }
202- cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")238+ cmd.Flags().StringVar(&service, "service", "", "Service that declares the volume")
239+ cmd.Flags().StringVar(&volume, "volume", "", "Declared volume name (optional)")
203 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")240 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204 return cmd241 return cmd
205 }242 }
206 243
244+func runVolumeBackupRun(ctx context.Context, plane ControlPlane, app, service, volume string, out io.Writer, jsonOut bool) error {
245+ result, err := plane.RunVolumeBackups(ctx, app, service, volume)
246+ if err != nil {
247+ // Partial results are reported before the error: an operator must
248+ // see which volumes completed.
249+ if result != nil && len(result.Backups) > 0 {
250+ if printErr := printVolumeBackupJobs(out, result.Backups, jsonOut); printErr != nil {
251+ return printErr
252+ }
253+ }
254+ return fmt.Errorf("failed to run volume backup: %w", err)
255+ }
256+ if jsonOut {
257+ return writeJSON(out, result)
258+ }
259+ return printVolumeBackupJobs(out, result.Backups, false)
260+}
261+
207 func newVolumeBackupStatusCmd() *cobra.Command {262 func newVolumeBackupStatusCmd() *cobra.Command {
208 var jsonOut bool263 var jsonOut bool
209 cmd := &cobra.Command{264 cmd := &cobra.Command{
210 Use: "status",265 Use: "status",
211 Short: "Show volume backup status",266 Short: "Show volume backup status",
212 RunE: func(cmd *cobra.Command, args []string) error {267 RunE: func(cmd *cobra.Command, args []string) error {
213- handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")268+ handle, err := resolveControlPlane(cliConfigPath)
214 if err != nil {269 if err != nil {
215 return err270 return err
216 }271 }
217 defer handle.close()272 defer handle.close()
218-273+ return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
219- jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
220- if err != nil {
221- return fmt.Errorf("failed to get volume backup status: %w", err)
222- }
223- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
224 },274 },
225 }275 }
226 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227 return cmd277 return cmd
228 }278 }
229 279
280+func runVolumeBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
281+ jobs, err := plane.VolumeBackupStatus(ctx)
282+ if err != nil {
283+ return fmt.Errorf("failed to get volume backup status: %w", err)
284+ }
285+ return printVolumeBackupJobs(out, jobs, jsonOut)
286+}
287+
230 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {288 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231 if len(jobs) == 0 {289 if len(jobs) == 0 {
232 if jsonOut {290 if jsonOut {
@@ -241,150 +299,19 @@ func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bo
241 return err299 return err
242 }300 }
243 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)301 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
244- if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {302+ if _, err := fmt.Fprintln(w, "APP\tSERVICE\tVOLUME\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
245 return err303 return err
246 }304 }
247 for _, job := range jobs {305 for _, job := range jobs {
248- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", job.Domain, job.VolumeName, job.ContainerName, job.Compression, job.Status, formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {306+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
307+ job.App, job.Service, job.VolumeName, job.Compression, job.Status,
308+ formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
249 return err309 return err
250 }310 }
251 }311 }
252 return w.Flush()312 return w.Flush()
253 }313 }
254 314
255-func newBackupRunCmd() *cobra.Command {
256- var dbName string
257-
258- cmd := &cobra.Command{
259- Use: "run <domain>",
260- Short: "Run backup now",
261- Args: cobra.ExactArgs(1),
262- RunE: func(cmd *cobra.Command, args []string) error {
263- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
264- if err != nil {
265- return err
266- }
267- defer handle.close()
268-
269- result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
270- if err != nil {
271- return fmt.Errorf("failed to run backup: %w", err)
272- }
273-
274- if result.Backup == nil {
275- return fmt.Errorf("backup run completed without backup payload")
276- }
277- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
278- return err
279- }
280-
281- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
282- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
283- return err
284- }
285- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
286- return err
287- }
288- if err := w.Flush(); err != nil {
289- return err
290- }
291- return nil
292- },
293- }
294-
295- cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)")
296- return cmd
297-}
298-
299-func newBackupDetectCmd() *cobra.Command {
300- return &cobra.Command{
301- Use: "detect <domain>",
302- Short: "Detect databases for domain",
303- Args: cobra.ExactArgs(1),
304- RunE: func(cmd *cobra.Command, args []string) error {
305- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
306- if err != nil {
307- return err
308- }
309- defer handle.close()
310-
311- dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
312- if err != nil {
313- return fmt.Errorf("failed to detect databases: %w", err)
314- }
315-
316- if len(dbs) == 0 {
317- if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
318- return err
319- }
320- return nil
321- }
322- if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
323- return err
324- }
325- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
326- if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
327- return err
328- }
329-
330- for _, db := range dbs {
331- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName); err != nil {
332- return err
333- }
334- }
335- if err := w.Flush(); err != nil {
336- return err
337- }
338-
339- return nil
340- },
341- }
342-}
343-
344-func newBackupStatusCmd() *cobra.Command {
345- return &cobra.Command{
346- Use: "status",
347- Short: "Show backup status",
348- RunE: func(cmd *cobra.Command, args []string) error {
349- handle, err := resolveControlPlane(configPath)
350- if err != nil {
351- return err
352- }
353- defer handle.close()
354-
355- jobs, err := handle.plane.BackupStatus(cmd.Context())
356- if err != nil {
357- return fmt.Errorf("failed to get backup status: %w", err)
358- }
359-
360- if len(jobs) == 0 {
361- if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
362- return err
363- }
364- return nil
365- }
366- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
367- return err
368- }
369- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
370- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
371- return err
372- }
373-
374- for _, job := range jobs {
375- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt)); err != nil {
376- return err
377- }
378- }
379- if err := w.Flush(); err != nil {
380- return err
381- }
382-
383- return nil
384- },
385- }
386-}
387-
388 func formatBackupTime(t *time.Time) string {315 func formatBackupTime(t *time.Time) string {
389 if t == nil {316 if t == nil {
390 return ""317 return ""
M internal/adapters/in/cli/backup_volume_test.go +13 -63
@@ -13,86 +13,36 @@ import (
13 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"13 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
14 )14 )
15 15
16-func withBackupControlPlane(t *testing.T, plane ControlPlane) {16+// backupRunPlane builds the control plane double the runner functions
17+// take directly: no resolver indirection is needed.
18+func backupRunPlane(t *testing.T) *climocks.MockControlPlane {
17 t.Helper()19 t.Helper()
18- old := backupResolveControlPlane20+ return climocks.NewMockControlPlane(t)
19- backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) {
20- return &controlPlaneHandle{plane: plane}, nil
21- }
22- t.Cleanup(func() { backupResolveControlPlane = old })
23-}
24-
25-func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26- plane := climocks.NewMockControlPlane(t)
27- old := backupResolveControlPlane
28- var gotDomain string
29- backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30- gotDomain = domainName
31- return &controlPlaneHandle{plane: plane}, nil
32- }
33- t.Cleanup(func() { backupResolveControlPlane = old })
34-
35- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36-
37- cmd := newVolumeBackupRunCmd()
38- cmd.SetArgs([]string{"app.example.com"})
39-
40- require.NoError(t, cmd.ExecuteContext(context.Background()))
41- require.Equal(t, "app.example.com", gotDomain)
42-}
43-
44-func TestVolumeBackupRun_JSONFlag(t *testing.T) {
45- plane := climocks.NewMockControlPlane(t)
46- withBackupControlPlane(t, plane)
47-
48- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
49- Status: "ok",
50- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
51- }, nil)
52-
53- cmd := newVolumeBackupRunCmd()
54- var out bytes.Buffer
55- cmd.SetOut(&out)
56- cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
57-
58- require.NoError(t, cmd.ExecuteContext(context.Background()))
59- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
60 }21 }
61 22
62 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {23 func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
63- plane := climocks.NewMockControlPlane(t)24+ plane := backupRunPlane(t)
64- withBackupControlPlane(t, plane)
65 25
66- plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)26+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "running"}}, nil)
67 27
68- cmd := newVolumeBackupStatusCmd()
69 var out bytes.Buffer28 var out bytes.Buffer
70- cmd.SetOut(&out)29+ require.NoError(t, runVolumeBackupStatus(context.Background(), plane, &out, true))
71- cmd.SetArgs([]string{"--json"})30+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"running","size_bytes":0}]`, out.String())
72-
73- require.NoError(t, cmd.ExecuteContext(context.Background()))
74- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
75 }31 }
76 32
77 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {33 func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
78- plane := climocks.NewMockControlPlane(t)34+ plane := backupRunPlane(t)
79- withBackupControlPlane(t, plane)
80 35
81 runErr := errors.New("one backup failed")36 runErr := errors.New("one backup failed")
82- plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{37+ plane.EXPECT().RunVolumeBackups(mock.Anything, "shop", "api", "data").Return(&dto.VolumeBackupRunResponse{
83 Status: "partial",38 Status: "partial",
84- Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},39+ Backups: []dto.VolumeBackupJob{{App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
85 Error: runErr.Error(),40 Error: runErr.Error(),
86 }, runErr)41 }, runErr)
87 42
88- cmd := newVolumeBackupRunCmd()
89- cmd.SilenceUsage = true
90 var out bytes.Buffer43 var out bytes.Buffer
91- cmd.SetOut(&out)44+ err := runVolumeBackupRun(context.Background(), plane, "shop", "api", "data", &out, true)
92- cmd.SetArgs([]string{"app.example.com", "--json"})
93-
94- err := cmd.ExecuteContext(context.Background())
95 require.Error(t, err)45 require.Error(t, err)
96 require.ErrorIs(t, err, runErr)46 require.ErrorIs(t, err, runErr)
97- require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())47+ require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"completed","size_bytes":0}]`, out.String())
98 }48 }
D internal/adapters/in/cli/bootstrap.go +0 -136
@@ -1,136 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "sort"
8- "strings"
9-
10- "github.com/spf13/cobra"
11-
12- "github.com/bnema/gordon/internal/adapters/dto"
13-)
14-
15-func newBootstrapCmd() *cobra.Command {
16- var attachments []string
17- var envPairs []string
18- var attachmentEnvPairs []string
19- var configPath string
20-
21- cmd := &cobra.Command{
22- Use: "bootstrap <domain> <image>",
23- Short: "Create a route, attachments, and secrets together",
24- Args: cobra.ExactArgs(2),
25- RunE: func(cmd *cobra.Command, args []string) error {
26- req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs)
27- if err != nil {
28- return err
29- }
30- return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout())
31- },
32- }
33-
34- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
35- cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
36- cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
37- cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
38-
39- return cmd
40-}
41-
42-func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) {
43- req := dto.BootstrapRequest{
44- Domain: args[0],
45- Image: args[1],
46- Attachments: append([]string(nil), attachments...),
47- Env: map[string]string{},
48- AttachmentEnv: map[string]map[string]string{},
49- }
50-
51- for _, pair := range envPairs {
52- parts := strings.SplitN(pair, "=", 2)
53- if len(parts) != 2 {
54- return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
55- }
56- req.Env[parts[0]] = parts[1]
57- }
58-
59- for _, pair := range attachmentEnvPairs {
60- serviceAndKV := strings.SplitN(pair, ":", 2)
61- if len(serviceAndKV) != 2 {
62- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
63- }
64- keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
65- if len(keyValue) != 2 {
66- return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
67- }
68- service := serviceAndKV[0]
69- if req.AttachmentEnv[service] == nil {
70- req.AttachmentEnv[service] = map[string]string{}
71- }
72- req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
73- }
74-
75- if len(req.Env) == 0 {
76- req.Env = nil
77- }
78- if len(req.AttachmentEnv) == 0 {
79- req.AttachmentEnv = nil
80- }
81-
82- return req, nil
83-}
84-
85-func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error {
86- handle, err := resolveControlPlane(configPath)
87- if err != nil {
88- return err
89- }
90- defer handle.close()
91-
92- resp, err := handle.plane.Bootstrap(ctx, req)
93- if resp != nil {
94- if writeErr := printBootstrapSummary(out, resp); writeErr != nil {
95- return writeErr
96- }
97- }
98- if err != nil {
99- return fmt.Errorf("failed to bootstrap: %w", err)
100- }
101- if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
102- return err
103- }
104- nextImage := req.Image
105- if resp != nil && resp.Image != "" {
106- nextImage = resp.Image
107- }
108- return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage))
109-}
110-
111-func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error {
112- if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
113- return err
114- }
115- if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
116- return err
117- }
118- if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
119- return err
120- }
121- for _, step := range resp.Steps {
122- if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
123- return err
124- }
125- }
126- if len(resp.Warnings) > 0 {
127- sorted := append([]string(nil), resp.Warnings...)
128- sort.Strings(sorted)
129- for _, warning := range sorted {
130- if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
131- return err
132- }
133- }
134- }
135- return nil
136-}
M internal/adapters/in/cli/ca.go +1 -1
@@ -96,7 +96,7 @@ func newCAInfoCmd() *cobra.Command {
96 }96 }
97 97
98 func resolveCADataDir() (string, error) {98 func resolveCADataDir() (string, error) {
99- local, err := GetLocalServices(configPath)99+ local, err := GetLocalServices(cliConfigPath)
100 if err != nil {100 if err != nil {
101 return "", err101 return "", err
102 }102 }
M internal/adapters/in/cli/config.go +5 -41
@@ -17,7 +17,7 @@ func newConfigCmd() *cobra.Command {
17 Short: "Inspect Gordon configuration",17 Short: "Inspect Gordon configuration",
18 }18 }
19 19
20- cmd.AddCommand(newConfigShowCmd())20+ cmd.AddCommand(newConfigShowCmd(), newConfigValidateCmd())
21 21
22 return cmd22 return cmd
23 }23 }
@@ -32,12 +32,12 @@ func newConfigShowCmd() *cobra.Command {
32 auto-route, network isolation, routes, and external routes.32 auto-route, network isolation, routes, and external routes.
33 33
34 Examples:34 Examples:
35- gordon config show35+ gordon daemon config show
36- gordon config show --json36+ gordon daemon config show --json
37- gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,37+ gordon daemon config show --remote prod`,
38 RunE: func(cmd *cobra.Command, _ []string) error {38 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()39 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)40+ handle, err := resolveControlPlane(cliConfigPath)
41 if err != nil {41 if err != nil {
42 return err42 return err
43 }43 }
@@ -72,9 +72,6 @@ func renderConfigTable(out io.Writer, config *remote.Config) error {
72 if err := renderConfigSummary(out, config); err != nil {72 if err := renderConfigSummary(out, config); err != nil {
73 return err73 return err
74 }74 }
75- if err := renderConfigRoutes(out, config); err != nil {
76- return err
77- }
78 return renderConfigExternalRoutes(out, config)75 return renderConfigExternalRoutes(out, config)
79 }76 }
80 77
@@ -99,9 +96,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
99 return err96 return err
100 }97 }
101 }98 }
102- if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
103- return err
104- }
105 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {99 if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
106 return err100 return err
107 }101 }
@@ -113,36 +107,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error {
113 return nil107 return nil
114 }108 }
115 109
116-func renderConfigRoutes(out io.Writer, config *remote.Config) error {
117- if err := cliWriteLine(out, ""); err != nil {
118- return err
119- }
120- if len(config.Routes) == 0 {
121- if err := cliWriteLine(out, cliRenderMuted("No routes configured")); err != nil {
122- return err
123- }
124- } else {
125- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
126- return err
127- }
128- rows := make([][]string, 0, len(config.Routes))
129- for _, route := range config.Routes {
130- rows = append(rows, []string{route.Domain, route.Image})
131- }
132- table := components.NewTable(
133- components.WithColumns([]components.TableColumn{
134- {Title: "Domain", Width: 30},
135- {Title: "Image", Width: 45},
136- }),
137- components.WithRows(rows),
138- )
139- if err := cliWriteLine(out, table.View()); err != nil {
140- return err
141- }
142- }
143- return nil
144-}
145-
146 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {110 func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
147 if err := cliWriteLine(out, ""); err != nil {111 if err := cliWriteLine(out, ""); err != nil {
148 return err112 return err
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 (
9 )9 )
10 10
11 // ControlPlane defines command operations available to CLI execution paths.11 // ControlPlane defines command operations available to CLI execution paths.
12-//12+// *remote.Client implements it for both the explicit remote and the
13-// Remote implementations call admin HTTP APIs.13+// owner-only local admin socket; tests substitute the generated mock.
14-// Local implementations call services directly in-process.
15 type ControlPlane interface {14 type ControlPlane interface {
16- ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)15+ // App lifecycle and reads. App mutations are daemon-owned for both
17- GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)16+ // the explicit remote and the owner-only local admin socket.
18- GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)17+ ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)
19- FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)18+ ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error)
20- AddRoute(ctx context.Context, route domain.Route) error19+ ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error)
21- UpdateRoute(ctx context.Context, route domain.Route) error20+ DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error)
22- RemoveRoute(ctx context.Context, routeDomain string) error21+ DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
23- Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)22+ StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
24-23+ StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
25- ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)24+ RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error)
26- SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error25+ RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
27- DeleteSecret(ctx context.Context, secretDomain, key string) error26+ OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
28- SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error27+ ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
29- DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error28+ SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
30-29+ DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
31- GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
32- GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error)
33- ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error)
34- CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)
35- FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
36- AddAttachment(ctx context.Context, domainOrGroup, image string) error
37- RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
38- GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
39- AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
40- RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
41 30
42 GetStatus(ctx context.Context) (*remote.Status, error)31 GetStatus(ctx context.Context) (*remote.Status, error)
43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)32 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
@@ -45,24 +34,20 @@ type ControlPlane interface {
45 Reload(ctx context.Context) error34 Reload(ctx context.Context) error
46 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)35 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
47 GetConfig(ctx context.Context) (*remote.Config, error)36 GetConfig(ctx context.Context) (*remote.Config, error)
48- DeployIntent(ctx context.Context, imageName string) error
49- Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
50- Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
51 ListTags(ctx context.Context, repository string) ([]string, error)37 ListTags(ctx context.Context, repository string) ([]string, error)
52 38
53- ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)39+ ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error)
54 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)40 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
55- RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)41+ RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error)
56- DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)42+ ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)
57- ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
58 VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)43 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)
60 45
61 GetProcessLogs(ctx context.Context, lines int) ([]string, error)46 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
62- GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
63 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)47 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
64- StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
65 48
66 ListVolumes(ctx context.Context) ([]dto.Volume, error)49 ListVolumes(ctx context.Context) ([]dto.Volume, error)
67 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)50 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
68 }51 }
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 (
15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16 )16 )
17 17
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-
27 func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {18 func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {
28 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {19 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 w.Header().Set("Content-Type", "application/json")20 w.Header().Set("Content-Type", "application/json")
30 w.WriteHeader(http.StatusPartialContent)21 w.WriteHeader(http.StatusPartialContent)
31 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{22 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
32 Status: "partial",23 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"}},
34 Error: "one volume failed",25 Error: "one volume failed",
35 }))26 }))
36 }))27 }))
37 t.Cleanup(server.Close)28 t.Cleanup(server.Close)
38 29
39- cp := NewRemoteControlPlane(remote.NewClient(server.URL))30+ cp := remote.NewClient(server.URL)
40- result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")31+ result, err := cp.RunVolumeBackups(context.Background(), "shop", "api", "data")
41 32
42 require.Error(t, err)33 require.Error(t, err)
43- assert.Contains(t, err.Error(), "run volume backups")34+ assert.Contains(t, err.Error(), "one volume failed")
44 require.NotNil(t, result)35 require.NotNil(t, result)
45 assert.Equal(t, "partial", result.Status)36 assert.Equal(t, "partial", result.Status)
46 require.Len(t, result.Backups, 1)37 require.Len(t, result.Backups, 1)
47 assert.Equal(t, "v1", result.Backups[0].ID)38 assert.Equal(t, "v1", result.Backups[0].ID)
48 }39 }
49 40
50-func TestRemoteControlPlane_VolumeBackupErrorsAreWrapped(t *testing.T) {41+func TestRemoteControlPlane_VolumeBackupErrorsKeepHTTPStatus(t *testing.T) {
51 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {42 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 http.Error(w, "boom", http.StatusBadGateway)43 http.Error(w, "boom", http.StatusBadGateway)
53 }))44 }))
54 t.Cleanup(server.Close)45 t.Cleanup(server.Close)
55 46
56- cp := NewRemoteControlPlane(remote.NewClient(server.URL))47+ cp := remote.NewClient(server.URL)
57 _, err := cp.VolumeBackupStatus(context.Background())48 _, err := cp.VolumeBackupStatus(context.Background())
58 49
59 require.Error(t, err)50 require.Error(t, err)
60- assert.Contains(t, err.Error(), "get volume backup status")
61 var httpErr *remote.HTTPError51 var httpErr *remote.HTTPError
62 require.True(t, errors.As(err, &httpErr))52 require.True(t, errors.As(err, &httpErr))
63 assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)53 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 (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8- "github.com/bnema/gordon/internal/app"
9 )8 )
10 9
11 type controlPlaneHandle struct {10 type controlPlaneHandle struct {
@@ -24,39 +23,32 @@ func (h *controlPlaneHandle) close() {
24 }23 }
25 }24 }
26 25
27-func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {26+var newLocalControlPlaneClient = remote.NewLocalClient
27+
28+func resolveDaemonClient() (*remote.Client, bool, error) {
28 client, isRemote, err := GetRemoteClient()29 client, isRemote, err := GetRemoteClient()
29- if err != nil {30+ if err != nil || isRemote {
30- return nil, err31+ return client, isRemote, err
31 }32 }
32- if isRemote {33+
33- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil34+ client, err = newLocalControlPlaneClient()
35+ if err != nil {
36+ return nil, false, fmt.Errorf("local control plane unavailable: %w", err)
34 }37 }
38+ return client, false, nil
39+}
35 40
36- return resolveLocalControlPlane(configPath)41+func resolveControlPlane(_ string) (*controlPlaneHandle, error) {
42+ client, isRemote, err := resolveDaemonClient()
43+ if err != nil {
44+ return nil, err
45+ }
46+ return &controlPlaneHandle{plane: client, isRemote: isRemote}, nil
37 }47 }
38 48
39 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {49 func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle {
40 client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...)50 client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...)
41- return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}51+ return &controlPlaneHandle{plane: 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- })
60 }52 }
61 53
62 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {54 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
@@ -73,17 +65,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co
73 if resolved != nil {65 if resolved != nil {
74 return newRemoteControlPlaneHandle(resolved), nil66 return newRemoteControlPlaneHandle(resolved), nil
75 }67 }
76- return resolveControlPlane(configPath)68+ return resolveControlPlane(cliConfigPath)
77-}
78-
79-func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
80- kernel, err := app.NewKernelQuiet(configPath)
81- if err != nil {
82- return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
83- }
84-
85- return &controlPlaneHandle{
86- plane: NewLocalControlPlane(kernel),
87- closeFn: kernel.Close,
88- }, nil
89 }69 }
M internal/adapters/in/cli/controlplane_resolver_test.go +94 -70
@@ -1,110 +1,134 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
4 "os"8 "os"
5 "path/filepath"9 "path/filepath"
6 "testing"10 "testing"
11+ "time"
7 12
13+ "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/localadmin"
9 )18 )
10 19
11-func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {20+func isolateControlPlaneResolver(t *testing.T) {
12- tmpDir := t.TempDir()21+ t.Helper()
13- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))22+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
14 t.Setenv("GORDON_REMOTE", "")23 t.Setenv("GORDON_REMOTE", "")
15 t.Setenv("GORDON_TOKEN", "")24 t.Setenv("GORDON_TOKEN", "")
16 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag25 originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
26+ originalFactory := newLocalControlPlaneClient
17 t.Cleanup(func() {27 t.Cleanup(func() {
18 remoteFlag = originalRemoteFlag28 remoteFlag = originalRemoteFlag
19 tokenFlag = originalTokenFlag29 tokenFlag = originalTokenFlag
20 insecureTLSFlag = originalInsecureTLSFlag30 insecureTLSFlag = originalInsecureTLSFlag
31+ newLocalControlPlaneClient = originalFactory
21 })32 })
22-
23 remoteFlag = ""33 remoteFlag = ""
24 tokenFlag = ""34 tokenFlag = ""
25 insecureTLSFlag = false35 insecureTLSFlag = false
36+}
26 37
27- configPath := filepath.Join(tmpDir, "gordon.toml")38+func localControlPlaneTestClient(t *testing.T, handler http.Handler) *remote.Client {
28- err := os.WriteFile(configPath, []byte(`[server]39+ t.Helper()
29-gordon_domain = "gordon.local"40+ dir := t.TempDir()
30-data_dir = "`+filepath.Join(tmpDir, "data")+`"41+ listener, _, err := localadmin.Listen(dir)
31-
32-[auth]
33-enabled = true
34-secrets_backend = "unsafe"
35-`), 0o600)
36 require.NoError(t, err)42 require.NoError(t, err)
43+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
44+ go func() { _ = server.Serve(listener) }()
45+ t.Cleanup(func() { _ = server.Close() })
46+ return remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
47+}
37 48
38- handle, err := resolveControlPlane(configPath)49+func TestResolveControlPlane_LocalUsesSocketBackedRemotePlane(t *testing.T) {
50+ isolateControlPlaneResolver(t)
51+ requests := make(chan string, 1)
52+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ requests <- r.URL.Path
54+ w.Header().Set("Content-Type", "application/json")
55+ _ = json.NewEncoder(w).Encode(remote.Status{RegistryDomain: "socket.local"})
56+ }))
57+ factoryCalls := 0
58+ newLocalControlPlaneClient = func() (*remote.Client, error) {
59+ factoryCalls++
60+ return client, nil
61+ }
62+
63+ // A deliberately unusable config path proves local resolution does not open a kernel/store.
64+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "missing", "gordon.toml"))
39 require.NoError(t, err)65 require.NoError(t, err)
40 require.NotNil(t, handle)66 require.NotNil(t, handle)
41- require.NotNil(t, handle.plane)67+ assert.False(t, handle.isRemote)
42- defer handle.close()68+ assert.IsType(t, &remote.Client{}, handle.plane)
43-}69+ assert.Equal(t, 1, factoryCalls)
44 70
45-func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {71+ status, err := handle.plane.GetStatus(context.Background())
46- tmpDir := t.TempDir()72+ require.NoError(t, err)
47- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))73+ assert.Equal(t, "socket.local", status.RegistryDomain)
48- t.Setenv("GORDON_REMOTE", "")74+ select {
49- t.Setenv("GORDON_TOKEN", "")75+ case path := <-requests:
50- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag76+ assert.Equal(t, "/admin/status", path)
51- t.Cleanup(func() {77+ case <-time.After(2 * time.Second):
52- remoteFlag = originalRemoteFlag78+ t.Fatal("socket server received no request")
53- tokenFlag = originalTokenFlag79+ }
54- insecureTLSFlag = originalInsecureTLSFlag80+}
55- })
56 81
57- remoteFlag = ""82+func TestResolveControlPlane_LocalFailsClosedWhenDaemonUnavailable(t *testing.T) {
58- tokenFlag = ""83+ isolateControlPlaneResolver(t)
59- insecureTLSFlag = false84+ newLocalControlPlaneClient = func() (*remote.Client, error) {
85+ return nil, remote.ErrDaemonUnavailable
86+ }
60 87
61- configPath := filepath.Join(tmpDir, "gordon.toml")88+ handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "would-create-store.toml"))
62- err := os.WriteFile(configPath, []byte(`[server]89+ require.Error(t, err)
63-gordon_domain = "gordon.local"90+ assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
64-data_dir = "`+filepath.Join(tmpDir, "data")+`"91+ assert.Nil(t, handle)
92+}
65 93
66-[auth]94+func TestResolveControlPlane_ExplicitRemoteDoesNotUseLocalFactory(t *testing.T) {
67-enabled = false95+ isolateControlPlaneResolver(t)
68-secrets_backend = "unsafe"96+ remoteFlag = "https://example.invalid"
69-`), 0o600)97+ tokenFlag = "token"
70- require.NoError(t, err)98+ newLocalControlPlaneClient = func() (*remote.Client, error) {
99+ t.Fatal("local factory invoked for explicit remote")
100+ return nil, errors.New("unreachable")
101+ }
71 102
72- handle, err := resolveControlPlane(configPath)103+ handle, err := resolveControlPlane("")
73 require.NoError(t, err)104 require.NoError(t, err)
74- require.NotNil(t, handle)105+ assert.True(t, handle.isRemote)
75- require.NotNil(t, handle.plane)106+ assert.IsType(t, &remote.Client{}, handle.plane)
76- defer handle.close()
77 }107 }
78 108
79 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {109 func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80- tmpDir := t.TempDir()110+ isolateControlPlaneResolver(t)
81- t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))111+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg"))
82- t.Setenv("GORDON_REMOTE", "")
83- t.Setenv("GORDON_TOKEN", "")
84- originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
85- t.Cleanup(func() {
86- remoteFlag = originalRemoteFlag
87- tokenFlag = originalTokenFlag
88- insecureTLSFlag = originalInsecureTLSFlag
89- })
90-
91 remoteFlag = "does-not-exist"112 remoteFlag = "does-not-exist"
92- tokenFlag = ""
93- insecureTLSFlag = false
94 113
95- configPath := filepath.Join(tmpDir, "gordon.toml")114+ handle, err := resolveControlPlane("")
96- err := os.WriteFile(configPath, []byte(`[server]115+ require.Error(t, err)
97-gordon_domain = "gordon.local"116+ assert.Nil(t, handle)
98-data_dir = "`+filepath.Join(tmpDir, "data")+`"117+ assert.Contains(t, err.Error(), "does-not-exist")
118+}
99 119
100-[auth]120+func TestResolveControlPlane_LocalIgnoresConfigAndStoreState(t *testing.T) {
101-enabled = false121+ isolateControlPlaneResolver(t)
102-secrets_backend = "unsafe"122+ client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
103-`), 0o600)123+ w.Header().Set("Content-Type", "application/json")
124+ _ = json.NewEncoder(w).Encode(remote.Status{})
125+ }))
126+ newLocalControlPlaneClient = func() (*remote.Client, error) { return client, nil }
127+
128+ lockedStore := filepath.Join(t.TempDir(), "state.db")
129+ require.NoError(t, os.WriteFile(lockedStore, []byte("active daemon store"), 0o600))
130+ handle, err := resolveControlPlane(lockedStore)
131+ require.NoError(t, err)
132+ _, err = handle.plane.GetStatus(context.Background())
104 require.NoError(t, err)133 require.NoError(t, err)
105-
106- handle, err := resolveControlPlane(configPath)
107- require.Error(t, err)
108- require.Nil(t, handle)
109- require.Contains(t, err.Error(), "does-not-exist")
110 }134 }
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{
69 func newImagesCmd() *cobra.Command {69 func newImagesCmd() *cobra.Command {
70 cmd := &cobra.Command{70 cmd := &cobra.Command{
71 Use: "images",71 Use: "images",
72- Short: "List and prune images",72+ Short: "Push, list, and prune images",
73- Long: `Inspect and clean up runtime and registry images.73+ Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`,
74-
75-These commands currently require remote mode with a configured target.`,
76 }74 }
77 75
76+ cmd.AddCommand(newPushCmd())
78 cmd.AddCommand(newImagesListCmd())77 cmd.AddCommand(newImagesListCmd())
79 cmd.AddCommand(newImagesPruneCmd())78 cmd.AddCommand(newImagesPruneCmd())
80 cmd.AddCommand(newImagesTagsCmd())79 cmd.AddCommand(newImagesTagsCmd())
@@ -89,14 +88,10 @@ func newImagesListCmd() *cobra.Command {
89 Use: "list",88 Use: "list",
90 Short: "List runtime images and registry tags",89 Short: "List runtime images and registry tags",
91 RunE: func(cmd *cobra.Command, _ []string) error {90 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote, err := GetRemoteClient()91+ client, _, err := resolveDaemonClient()
93 if err != nil {92 if err != nil {
94 return err93 return err
95 }94 }
96- if !isRemote {
97- return fmt.Errorf("images commands require a configured remote target")
98- }
99-
100 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)95 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
101 },96 },
102 }97 }
@@ -115,21 +110,21 @@ func newImagesPruneCmd() *cobra.Command {
115 Long: `Remove dangling runtime images and/or old registry tags.110 Long: `Remove dangling runtime images and/or old registry tags.
116 111
117 By default both runtime and registry cleanup run, keeping latest + 3 previous112 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.`,
119 RunE: func(cmd *cobra.Command, _ []string) error {118 RunE: func(cmd *cobra.Command, _ []string) error {
120- client, isRemote, err := GetRemoteClient()119+ client, _, err := resolveDaemonClient()
121 if err != nil {120 if err != nil {
122 return err121 return err
123 }122 }
124- if !isRemote {
125- return fmt.Errorf("images commands require a configured remote target")
126- }
127-
128 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())123 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
129 },124 },
130 }125 }
131 126
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")
133 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,128 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
134 "Number of previous non-latest tags to keep per repository (latest is always kept)")129 "Number of previous non-latest tags to keep per repository (latest is always kept)")
135 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")130 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
@@ -150,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command {
150 Examples:145 Examples:
151 gordon images tags myapp146 gordon images tags myapp
152 gordon images tags myapp --json147 gordon images tags myapp --json
153- gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,148+ gordon images tags myapp --remote prod`,
154 Args: cobra.ExactArgs(1),149 Args: cobra.ExactArgs(1),
155 RunE: func(cmd *cobra.Command, args []string) error {150 RunE: func(cmd *cobra.Command, args []string) error {
156 ctx := cmd.Context()151 ctx := cmd.Context()
@@ -357,7 +352,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
357 }352 }
358 }353 }
359 354
360- req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)355+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, false)
361 356
362 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)357 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
363 if err != nil {358 if err != nil {
@@ -378,12 +373,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
378 }373 }
379 374
380 if pruneRegistry {375 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 {
382 return err380 return err
383 }381 }
384 382
385- err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))383+ return cliRenderPruneSummary(out, resp.Plan)
386- return err
387 }384 }
388 385
389 func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {386 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
391 return err388 return err
392 }389 }
393 390
394- if pruneDangling {391+ // A dry run executes the full inventory and planning path: only the
395- images, err := client.ListImages(ctx)392+ // deletion step is skipped, so the report is the executed plan.
396- if err != nil {393+ req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true)
397- return fmt.Errorf("failed to list images: %w", err)394+ resp, err := client.PruneImages(ctx, req)
398- }395+ if err != nil {
399-396+ return fmt.Errorf("failed to plan image prune: %w", err)
400- danglingCount := 0397+ }
401- for _, img := range images {398+ if resp == nil {
402- if img.Dangling {399+ return fmt.Errorf("failed to plan image prune: empty response")
403- danglingCount++400+ }
404- }
405- }
406 401
407- if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {402+ if pruneDangling {
408- return err403+ if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil {
409- }
410- } else {
411- if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
412 return err404 return err
413 }405 }
406+ } else if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
407+ return err
414 }408 }
415 409
416 if pruneRegistry {410 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
418 }416 }
419 417
420- return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))418+ return cliRenderPruneSummary(out, resp.Plan)
421 }419 }
422 420
423 func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {421 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]
561 return kept559 return kept
562 }560 }
563 561
564-func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {562+func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry, dryRun bool) dto.ImagePruneRequest {
565 return dto.ImagePruneRequest{563 return dto.ImagePruneRequest{
566 KeepLast: &keepReleases,564 KeepLast: &keepReleases,
567 PruneDangling: &pruneDangling,565 PruneDangling: &pruneDangling,
568 PruneRegistry: &pruneRegistry,566 PruneRegistry: &pruneRegistry,
567+ DryRun: &dryRun,
569 }568 }
570 }569 }
571 570
M internal/adapters/in/cli/images_test.go +35 -12
@@ -297,7 +297,18 @@ func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
297 297
298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {298 func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299 client := &imagesClientMock{299 client := &imagesClientMock{
300- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},300+ pruneResp: &dto.ImagePruneResponse{
301+ Plan: dto.PruneSummary{
302+ Applied: false,
303+ Eligible: 2,
304+ Protected: 1,
305+ Candidates: []dto.PruneCandidate{
306+ {Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"},
307+ {Kind: "runtime-image", Ref: "sha256:b", Verdict: "eligible"},
308+ {Kind: "registry-tag", Ref: "app:v1", Verdict: "protected", Reasons: []string{"protected-active-service"}},
309+ },
310+ },
311+ },
301 }312 }
302 313
303 var out bytes.Buffer314 var out bytes.Buffer
@@ -307,15 +318,22 @@ func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
307 318
308 text := out.String()319 text := out.String()
309 assert.Contains(t, text, "Dry run")320 assert.Contains(t, text, "Dry run")
310- assert.Contains(t, text, "would prune 2 dangling runtime images")321+ assert.Contains(t, text, "would delete 2 images")
311- assert.Contains(t, text, "would keep latest + 3 previous tags")322+ assert.Contains(t, text, "latest + 3 previous tags")
312- assert.Equal(t, 1, client.listImagesCalls)323+ assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
313- assert.Equal(t, 0, client.pruneCalls)324+ assert.Contains(t, text, "protected-active-service")
325+ assert.Equal(t, 1, client.pruneCalls)
326+ assert.NotNil(t, client.lastPruneOpts.DryRun)
327+ assert.True(t, *client.lastPruneOpts.DryRun)
314 }328 }
315 329
316 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {330 func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317 client := &imagesClientMock{331 client := &imagesClientMock{
318- listImagesResp: []dto.Image{{Dangling: true}},332+ pruneResp: &dto.ImagePruneResponse{
333+ Plan: dto.PruneSummary{
334+ Candidates: []dto.PruneCandidate{{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"}},
335+ },
336+ },
319 }337 }
320 338
321 var out bytes.Buffer339 var out bytes.Buffer
@@ -324,14 +342,18 @@ func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
324 require.NoError(t, err)342 require.NoError(t, err)
325 343
326 text := out.String()344 text := out.String()
327- assert.Contains(t, text, "would prune 1 dangling runtime images")345+ assert.Contains(t, text, "would delete 1 images")
328 assert.Contains(t, text, "Registry cleanup skipped")346 assert.Contains(t, text, "Registry cleanup skipped")
329- assert.Equal(t, 0, client.pruneCalls)347+ assert.Equal(t, 1, client.pruneCalls)
330 }348 }
331 349
332 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {350 func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333 client := &imagesClientMock{351 client := &imagesClientMock{
334- listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},352+ pruneResp: &dto.ImagePruneResponse{
353+ Plan: dto.PruneSummary{
354+ Candidates: []dto.PruneCandidate{{Kind: "registry-tag", Ref: "app:v1", Verdict: "eligible"}},
355+ },
356+ },
335 }357 }
336 358
337 var out bytes.Buffer359 var out bytes.Buffer
@@ -341,8 +363,8 @@ func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
341 363
342 text := out.String()364 text := out.String()
343 assert.Contains(t, text, "Runtime cleanup skipped")365 assert.Contains(t, text, "Runtime cleanup skipped")
344- assert.Contains(t, text, "would keep latest + 5 previous tags")366+ assert.Contains(t, text, "latest + 5 previous tags")
345- assert.Equal(t, 0, client.pruneCalls)367+ assert.Equal(t, 1, client.pruneCalls)
346 }368 }
347 369
348 // ---------------------------------------------------------------------------370 // ---------------------------------------------------------------------------
@@ -484,7 +506,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
484 t.Cleanup(func() { pruneConfirmFunc = origConfirm })506 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485 507
486 client := &imagesClientMock{508 client := &imagesClientMock{
487- listImagesResp: []dto.Image{{Dangling: true}},509+ pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
488 }510 }
489 511
490 var out bytes.Buffer512 var out bytes.Buffer
@@ -493,6 +515,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
493 require.NoError(t, err)515 require.NoError(t, err)
494 516
495 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")517 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518+ assert.Equal(t, 1, client.pruneCalls)
496 }519 }
497 520
498 // ---------------------------------------------------------------------------521 // ---------------------------------------------------------------------------
M internal/adapters/in/cli/json_parity_test.go +3 -9
@@ -12,10 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
12 name string12 name string
13 builder func() *cobra.Command13 builder func() *cobra.Command
14 }{14 }{
15- {"routes list", newRoutesListCmd},
16- {"routes status", newRoutesStatusCmd},
17- {"attachments list", newAttachmentsListCmd},
18- {"secrets list", newSecretsListCmd},
19 {"images list", newImagesListCmd},15 {"images list", newImagesListCmd},
20 {"backup list", newBackupListCmd},16 {"backup list", newBackupListCmd},
21 {"backup volumes list", newVolumeBackupListCmd},17 {"backup volumes list", newVolumeBackupListCmd},
@@ -23,12 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
23 {"backup volumes status", newVolumeBackupStatusCmd},19 {"backup volumes status", newVolumeBackupStatusCmd},
24 {"auth token list", newTokenListCmd},20 {"auth token list", newTokenListCmd},
25 {"remotes list", newRemotesListCmd},21 {"remotes list", newRemotesListCmd},
26- {"pin list", newPinListCmd},22+ {"daemon config show", newConfigShowCmd},
27- {"config show", newConfigShowCmd},23+ {"daemon networks", newNetworksCmd},
28- {"routes show", newRoutesShowCmd},
29- {"networks list", newNetworksListCmd},
30 {"images tags", newImagesTagsCmd},24 {"images tags", newImagesTagsCmd},
31- {"tls status", newTLSStatusCmd},25+ {"daemon tls", newTLSCmd},
32 }26 }
33 27
34 for _, tc := range commands {28 for _, tc := range commands {
M internal/adapters/in/cli/json_test.go +4 -192
@@ -10,37 +10,9 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
15 )14 )
16 15
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-
44 func TestImagesList_JSONFlag_Accepted(t *testing.T) {16 func TestImagesList_JSONFlag_Accepted(t *testing.T) {
45 cmd := newImagesListCmd()17 cmd := newImagesListCmd()
46 f := cmd.Flags().Lookup("json")18 f := cmd.Flags().Lookup("json")
@@ -77,15 +49,6 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
77 }49 }
78 }50 }
79 51
80-func TestPinList_JSONFlag_Accepted(t *testing.T) {
81- cmd := newPinListCmd()
82- f := cmd.Flags().Lookup("json")
83- assert.NotNil(t, f)
84- if f != nil {
85- assert.Equal(t, "false", f.DefValue)
86- }
87-}
88-
89 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {52 func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
90 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)53 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
91 images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}54 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) {
113 76
114 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {77 func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
115 startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)78 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}}
117 80
118 payload, err := json.Marshal(jobs)81 payload, err := json.Marshal(jobs)
119 require.NoError(t, err)82 require.NoError(t, err)
@@ -122,7 +85,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
122 require.NoError(t, json.Unmarshal(payload, &got))85 require.NoError(t, json.Unmarshal(payload, &got))
123 require.Len(t, got, 1)86 require.Len(t, got, 1)
124 assert.Equal(t, "b1", got[0].ID)87 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)
126 }91 }
127 92
128 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {93 func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
@@ -172,159 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
172 assert.True(t, got[0].InsecureTLS)137 assert.True(t, got[0].InsecureTLS)
173 }138 }
174 139
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-
328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {140 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
329 var out bytes.Buffer141 var out bytes.Buffer
330 err := writeJSON(&out, map[string]any{"ok": true})142 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/local.go +2 -63
@@ -4,25 +4,18 @@ package cli
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7- "path/filepath"
8 "strings"7 "strings"
9 8
10- "github.com/bnema/zerowrap"
11 "github.com/spf13/viper"9 "github.com/spf13/viper"
12 10
13- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
14 "github.com/bnema/gordon/internal/app"11 "github.com/bnema/gordon/internal/app"
15 "github.com/bnema/gordon/internal/boundaries/in"12 "github.com/bnema/gordon/internal/boundaries/in"
16- "github.com/bnema/gordon/internal/boundaries/out"
17- "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/config"13 "github.com/bnema/gordon/internal/usecase/config"
19- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
20 )14 )
21 15
22 // LocalServices provides direct access to local services for CLI operations.16 // LocalServices provides direct access to local services for CLI operations.
23 type LocalServices struct {17 type LocalServices struct {
24 configSvc in.ConfigService18 configSvc in.ConfigService
25- secretSvc in.SecretService
26 dataDir string19 dataDir string
27 tlsEnabled bool20 tlsEnabled bool
28 }21 }
@@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService {
32 return l.configSvc25 return l.configSvc
33 }26 }
34 27
35-// GetSecretService returns the secret service.
36-func (l *LocalServices) GetSecretService() in.SecretService {
37- return l.secretSvc
38-}
39-
40 // GetDataDir returns the data directory.28 // GetDataDir returns the data directory.
41 func (l *LocalServices) GetDataDir() string {29 func (l *LocalServices) GetDataDir() string {
42 return l.dataDir30 return l.dataDir
@@ -49,7 +37,7 @@ func (l *LocalServices) HasInternalTLS() bool {
49 37
50 // GetLocalServices creates local services for CLI operations.38 // GetLocalServices creates local services for CLI operations.
51 // It loads the config and initializes services without starting the server.39 // 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) {
53 // Set up viper with defaults41 // Set up viper with defaults
54 v := viper.New()42 v := viper.New()
55 v.SetDefault("server.port", 8088)43 v.SetDefault("server.port", 8088)
@@ -57,7 +45,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
57 v.SetDefault("server.data_dir", app.DefaultDataDir())45 v.SetDefault("server.data_dir", app.DefaultDataDir())
58 46
59 // Configure viper with config file paths47 // Configure viper with config file paths
60- app.ConfigureViper(v, configPath)48+ app.ConfigureViper(v, cliConfigPath)
61 49
62 // Read config file50 // Read config file
63 if err := v.ReadInConfig(); err != nil {51 if err := v.ReadInConfig(); err != nil {
@@ -67,8 +55,6 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
67 // Config file not found is OK for some operations55 // Config file not found is OK for some operations
68 }56 }
69 57
70- log := zerowrap.New(cliLogConfig)
71-
72 // Create config service (without event bus for CLI operations)58 // Create config service (without event bus for CLI operations)
73 configSvc := config.NewService(v, nil)59 configSvc := config.NewService(v, nil)
74 ctx := context.Background()60 ctx := context.Background()
@@ -76,27 +62,13 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
76 return nil, fmt.Errorf("failed to load configuration: %w", err)62 return nil, fmt.Errorf("failed to load configuration: %w", err)
77 }63 }
78 64
79- // Determine data directory and env directory
80 dataDir := v.GetString("server.data_dir")65 dataDir := v.GetString("server.data_dir")
81 if dataDir == "" {66 if dataDir == "" {
82 dataDir = app.DefaultDataDir()67 dataDir = app.DefaultDataDir()
83 }68 }
84 69
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-
97 return &LocalServices{70 return &LocalServices{
98 configSvc: configSvc,71 configSvc: configSvc,
99- secretSvc: secretSvc,
100 dataDir: dataDir,72 dataDir: dataDir,
101 tlsEnabled: hasLocalTLSCapableEntrypoint(v),73 tlsEnabled: hasLocalTLSCapableEntrypoint(v),
102 }, nil74 }, nil
@@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
115 }87 }
116 return false88 return false
117 }89 }
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
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/zerowrap"
7 "github.com/spf13/viper"6 "github.com/spf13/viper"
8 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11- "github.com/bnema/gordon/internal/domain"
12 )8 )
13 9
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-
41 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {10 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
42 for _, tt := range []struct {11 for _, tt := range []struct {
43 name string12 name string
@@ -56,38 +25,3 @@ func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
56 })25 })
57 }26 }
58 }27 }
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 {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockimagesClient {19 }) *MockimagesClient {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockimagesClient{}24 mock := &MockimagesClient{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
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+ })
24 33
25 return mock34 return mock
26 }35 }
M internal/adapters/in/cli/mocks/mock_push_image_ops.go +10 -1
@@ -16,10 +16,19 @@ func NewMockpushImageOps(t interface {
16 mock.TestingT16 mock.TestingT
17 Cleanup(func())17 Cleanup(func())
18 }) *MockpushImageOps {18 }) *MockpushImageOps {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
19 mock := &MockpushImageOps{}23 mock := &MockpushImageOps{}
20 mock.Mock.Test(t)24 mock.Mock.Test(t)
21 25
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+ })
23 32
24 return mock33 return mock
25 }34 }
M internal/adapters/in/cli/mocks/mock_volumes_client.go +10 -1
@@ -17,10 +17,19 @@ func NewMockvolumesClient(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockvolumesClient {19 }) *MockvolumesClient {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockvolumesClient{}24 mock := &MockvolumesClient{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
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+ })
24 33
25 return mock34 return mock
26 }35 }
M internal/adapters/in/cli/networks.go +6 -17
@@ -12,32 +12,21 @@ import (
12 )12 )
13 13
14 func newNetworksCmd() *cobra.Command {14 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 {
26 var jsonOut bool15 var jsonOut bool
27 16
28 cmd := &cobra.Command{17 cmd := &cobra.Command{
29- Use: "list",18+ Use: "networks",
30- Short: "List Gordon-managed Docker networks",19+ Short: "List Gordon-managed networks",
31 Long: `Display Docker networks managed by Gordon, including which containers20 Long: `Display Docker networks managed by Gordon, including which containers
32 are connected to each network.21 are connected to each network.
33 22
34 Examples:23 Examples:
35- gordon networks list24+ gordon daemon networks
36- gordon networks list --json25+ gordon daemon networks --json
37- gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,26+ gordon daemon networks --remote prod`,
38 RunE: func(cmd *cobra.Command, _ []string) error {27 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()28 ctx := cmd.Context()
40- handle, err := resolveControlPlane(configPath)29+ handle, err := resolveControlPlane(cliConfigPath)
41 if err != nil {30 if err != nil {
42 return err31 return err
43 }32 }
D internal/adapters/in/cli/parity_matrix_test.go +0 -91
@@ -1,91 +0,0 @@
1-package cli
2-
3-import (
4- "go/ast"
5- "go/parser"
6- "go/token"
7- "os"
8- "strings"
9- "testing"
10-)
11-
12-type uiAdoptionExpectation struct {
13- family string
14- file string
15- functions []string
16-}
17-
18-var uiAdoptionExpectations = []uiAdoptionExpectation{
19- {
20- family: "root/server",
21- file: "root.go",
22- functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
23- },
24- {
25- family: "backups",
26- file: "backup.go",
27- functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
28- },
29- {
30- family: "images",
31- file: "images.go",
32- functions: []string{"runImagesList", "runImagesPrune"},
33- },
34-}
35-
36-func TestLocalParityMatrix(t *testing.T) {
37- checks := []struct {
38- file string
39- legacyText string
40- command string
41- }{
42- {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
43- // Guards against reintroducing the old rollback-only remote-mode error text during the command rename.
44- {file: "pin.go", legacyText: "rollback requires remote mode", command: "gordon pin"},
45- {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
46- {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
47- {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
48- }
49-
50- for _, check := range checks {
51- content, err := os.ReadFile(check.file)
52- if err != nil {
53- t.Fatalf("failed to read %s: %v", check.file, err)
54- }
55- if strings.Contains(string(content), check.legacyText) {
56- t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file)
57- }
58- }
59-}
60-
61-func TestUIAdoptionMatrixCoverage(t *testing.T) {
62- for _, expect := range uiAdoptionExpectations {
63- t.Run(expect.family, func(t *testing.T) {
64- if len(expect.functions) == 0 {
65- t.Fatalf("ui adoption matrix for %s is empty", expect.family)
66- }
67-
68- fset := token.NewFileSet()
69- fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
70- if err != nil {
71- t.Fatalf("failed to parse %s: %v", expect.file, err)
72- }
73-
74- found := make(map[string]bool, len(expect.functions))
75- ast.Inspect(fileNode, func(n ast.Node) bool {
76- fn, ok := n.(*ast.FuncDecl)
77- if !ok {
78- return true
79- }
80- found[fn.Name.Name] = true
81- return true
82- })
83-
84- for _, fn := range expect.functions {
85- if !found[fn] {
86- t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
87- }
88- }
89- })
90- }
91-}
D internal/adapters/in/cli/pin.go +0 -249
@@ -1,249 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "slices"
8- "sort"
9- "strings"
10-
11- "github.com/spf13/cobra"
12- "golang.org/x/mod/semver"
13-
14- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16- "github.com/bnema/gordon/internal/domain"
17-)
18-
19-func newPinCmd() *cobra.Command {
20- var targetTag string
21-
22- cmd := &cobra.Command{
23- Use: "pin <domain>",
24- Short: "Pin a route to a specific image tag",
25- Long: `Lists available image tags for a domain and deploys the selected version.
26-Tags are read from the Gordon registry.
27-
28-Examples:
29- gordon pin myapp.example.com --remote ...
30- gordon pin myapp.example.com --tag v1.0.0 --remote ...
31- gordon pin list myapp.example.com --remote ...`,
32- Args: cobra.ExactArgs(1),
33- RunE: func(cmd *cobra.Command, args []string) error {
34- return runPin(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], targetTag)
35- },
36- }
37-
38- cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39- cmd.AddCommand(newPinListCmd())
40-
41- return cmd
42-}
43-
44-func newPinListCmd() *cobra.Command {
45- var jsonOut bool
46-
47- cmd := &cobra.Command{
48- Use: "list <domain>",
49- Short: "List available tags",
50- Args: cobra.ExactArgs(1),
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return runPinList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
53- },
54- }
55-
56- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57-
58- return cmd
59-}
60-
61-func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
62- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
63- if err != nil {
64- return fmt.Errorf("failed to resolve control plane: %w", err)
65- }
66- defer handle.close()
67-
68- route, err := handle.plane.GetRoute(ctx, pinDomain)
69- if err != nil {
70- return fmt.Errorf("failed to get route: %w", err)
71- }
72-
73- _, imageName, currentTag := parseImageRef(route.Image)
74- if imageName == "" {
75- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
76- }
77-
78- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
79- if err != nil {
80- return fmt.Errorf("failed to fetch tags: %w", err)
81- }
82-
83- selectedTag, err := selectTag(targetTag, tags, currentTag, pinDomain, out)
84- if err != nil {
85- return fmt.Errorf("failed to select tag: %w", err)
86- }
87- if selectedTag == "" {
88- return nil
89- }
90-
91- if selectedTag == currentTag {
92- return cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
93- }
94-
95- return deploySelectedTag(ctx, handle.plane, route, out, ew, pinDomain, imageName, selectedTag)
96-}
97-
98-func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
99- handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
100- if err != nil {
101- return fmt.Errorf("failed to resolve control plane: %w", err)
102- }
103- defer handle.close()
104-
105- route, err := handle.plane.GetRoute(ctx, pinDomain)
106- if err != nil {
107- return fmt.Errorf("failed to get route: %w", err)
108- }
109-
110- _, imageName, currentTag := parseImageRef(route.Image)
111- if imageName == "" {
112- return fmt.Errorf("cannot parse image name from route: %s", route.Image)
113- }
114-
115- tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
116- if err != nil {
117- return fmt.Errorf("failed to fetch tags: %w", err)
118- }
119-
120- return printPinTags(w, pinDomain, currentTag, tags, jsonOut)
121-}
122-
123-func printPinTags(w io.Writer, pinDomain, currentTag string, tags []string, jsonOut bool) error {
124- if jsonOut {
125- return writeJSON(w, map[string]any{
126- "domain": pinDomain,
127- "current_tag": currentTag,
128- "tags": tags,
129- })
130- }
131-
132- if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(pinDomain)); err != nil {
133- return err
134- }
135- for _, tag := range tags {
136- suffix := ""
137- if tag == currentTag {
138- suffix = " (current)"
139- }
140- if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil {
141- return err
142- }
143- }
144- return nil
145-}
146-
147-func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
148- tags, err := cp.ListTags(ctx, imageName)
149- if err != nil {
150- return nil, fmt.Errorf("failed to list tags: %w", err)
151- }
152- if len(tags) == 0 {
153- return nil, fmt.Errorf("no tags found for %s", imageName)
154- }
155-
156- return sortSemverTags(tags), nil
157-}
158-
159-// sortSemverTags normalizes, classifies, and sorts tags.
160-// Semver tags (with or without 'v' prefix) are sorted in descending order first,
161-// followed by non-semver tags in descending lexicographic order.
162-func sortSemverTags(tags []string) []string {
163- var semverTags, otherTags []string
164- for _, tag := range tags {
165- sv := tag
166- if !strings.HasPrefix(sv, "v") {
167- sv = "v" + sv
168- }
169- if semver.IsValid(sv) {
170- semverTags = append(semverTags, tag)
171- } else {
172- otherTags = append(otherTags, tag)
173- }
174- }
175-
176- // Sort semver tags in descending order (latest first)
177- sort.Slice(semverTags, func(i, j int) bool {
178- si, sj := semverTags[i], semverTags[j]
179- if !strings.HasPrefix(si, "v") {
180- si = "v" + si
181- }
182- if !strings.HasPrefix(sj, "v") {
183- sj = "v" + sj
184- }
185- return semver.Compare(si, sj) > 0
186- })
187-
188- // Sort non-semver tags in descending lexicographic order
189- sort.Slice(otherTags, func(i, j int) bool {
190- return otherTags[i] > otherTags[j]
191- })
192-
193- // Combine: semver tags first, then other tags
194- return append(semverTags, otherTags...)
195-}
196-
197-func selectTag(targetTag string, tags []string, currentTag, pinDomain string, out io.Writer) (string, error) {
198- if targetTag != "" {
199- if !validateTagExists(targetTag, tags) {
200- return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
201- }
202- return targetTag, nil
203- }
204-
205- selectedTag, err := components.RunSelector(
206- fmt.Sprintf("Select version for %s:", pinDomain),
207- tags,
208- currentTag,
209- )
210- if err != nil {
211- return "", err
212- }
213- if selectedTag == "" {
214- _ = cliWriteLine(out, "Cancelled.")
215- }
216- return selectedTag, nil
217-}
218-
219-func validateTagExists(targetTag string, tags []string) bool {
220- return slices.Contains(tags, targetTag)
221-}
222-
223-func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, out, ew io.Writer, pinDomain, imageName, selectedTag string) error {
224- registry, _, _ := parseImageRef(route.Image)
225- oldImage := route.Image
226- route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
227-
228- if err := cliWritef(out, "Pinning to %s...\n", styles.Theme.Bold.Render(selectedTag)); err != nil {
229- return err
230- }
231-
232- if err := cp.UpdateRoute(ctx, *route); err != nil {
233- return fmt.Errorf("failed to update route: %w", err)
234- }
235-
236- result, err := cp.Deploy(ctx, pinDomain)
237- if err != nil {
238- formattedErr := formatDeployFailure(err)
239- // Attempt to revert route to previous image
240- route.Image = oldImage
241- if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242- _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243- return fmt.Errorf("failed to deploy; revert failed: %w; deploy error: %v", revertErr, formattedErr)
244- }
245- return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr)
246- }
247- containerID := shortContainerID(result.ContainerID)
248- return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
249-}
D internal/adapters/in/cli/pin_test.go +0 -104
@@ -1,104 +0,0 @@
1-package cli
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "testing"
8-
9- climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
10- "github.com/bnema/gordon/internal/domain"
11- "github.com/stretchr/testify/assert"
12-)
13-
14-type failingWriter struct{}
15-
16-func (failingWriter) Write(_ []byte) (int, error) {
17- return 0, errors.New("write failed")
18-}
19-
20-func TestSortSemverTags(t *testing.T) {
21- tests := []struct {
22- name string
23- tags []string
24- wantTags []string
25- }{
26- {
27- name: "v-prefixed tags sorted descending",
28- tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"},
29- wantTags: []string{"v2.0.0", "v1.5.0", "v1.0.0"},
30- },
31- {
32- name: "non-v-prefixed tags sorted as semver",
33- tags: []string{"1.0.0", "2.0.0", "1.5.0"},
34- wantTags: []string{"2.0.0", "1.5.0", "1.0.0"},
35- },
36- {
37- name: "mixed v and non-v prefixed",
38- tags: []string{"v1.0.0", "2.0.0", "1.5.0"},
39- wantTags: []string{"2.0.0", "1.5.0", "v1.0.0"}, // all recognized as semver, sorted desc
40- },
41- {
42- name: "pre-release tags",
43- tags: []string{"v1.0.0-rc1", "v1.0.0", "1.0.0-beta"},
44- wantTags: []string{"v1.0.0", "v1.0.0-rc1", "1.0.0-beta"}, // release > pre-release
45- },
46- {
47- name: "non-semver tags appended after",
48- tags: []string{"latest", "v1.0.0", "dev"},
49- wantTags: []string{"v1.0.0", "latest", "dev"},
50- },
51- }
52-
53- for _, tt := range tests {
54- t.Run(tt.name, func(t *testing.T) {
55- gotTags := sortSemverTags(tt.tags)
56- assert.Equal(t, tt.wantTags, gotTags)
57- })
58- }
59-}
60-
61-func TestValidateTagExists(t *testing.T) {
62- assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
63- assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
64-}
65-
66-func TestPrintPinTags_WritesToProvidedWriter(t *testing.T) {
67- var buf bytes.Buffer
68-
69- err := printPinTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
70- assert.NoError(t, err)
71-
72- output := buf.String()
73- assert.Contains(t, output, "Available tags for")
74- assert.Contains(t, output, "myapp.example.com")
75- assert.Contains(t, output, "- v1.2.0 (current)")
76- assert.Contains(t, output, "- v1.1.0")
77-}
78-
79-func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
80- err := printPinTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
81- assert.Error(t, err)
82- assert.Contains(t, err.Error(), "write failed")
83-}
84-
85-func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
86- cp := climocks.NewMockControlPlane(t)
87- route := &domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}
88- deployErr := &domain.DeployFailureError{
89- Summary: "failed to deploy",
90- Cause: "health check failed",
91- Hint: "check DATABASE_URL",
92- }
93-
94- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2"}).Return(nil).Once()
95- cp.EXPECT().Deploy(context.Background(), "app.example.com").Return(nil, deployErr).Once()
96- cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}).Return(nil).Once()
97-
98- err := deploySelectedTag(context.Background(), cp, route, &bytes.Buffer{}, &bytes.Buffer{}, "app.example.com", "myapp", "v2")
99- assert.Error(t, err)
100- assert.Contains(t, err.Error(), "failed to deploy")
101- assert.Contains(t, err.Error(), "Cause: health check failed")
102- assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103- assert.Contains(t, err.Error(), "route reverted to previous image")
104-}
M internal/adapters/in/cli/presentation.go +0 -8
@@ -63,11 +63,3 @@ func cliRenderWarning(msg string) string {
63 func cliRenderInfo(msg string) string {63 func cliRenderInfo(msg string) string {
64 return styles.RenderInfo(msg)64 return styles.RenderInfo(msg)
65 }65 }
66-
67-func shortContainerID(id string) string {
68- if len(id) > 12 {
69- return id[:12]
70- }
71-
72- return id
73-}
D internal/adapters/in/cli/preview.go +0 -469
@@ -1,469 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "os"
8- "os/exec"
9- "strings"
10- "text/tabwriter"
11- "time"
12-
13- tea "github.com/charmbracelet/bubbletea"
14- "github.com/spf13/cobra"
15-
16- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18- "github.com/bnema/gordon/internal/domain"
19- "github.com/bnema/gordon/internal/usecase/auto/preview"
20-)
21-
22-func newPreviewCmd() *cobra.Command {
23- cmd := &cobra.Command{
24- Use: "preview",
25- Short: "Create or manage preview environments",
26- Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
27- }
28-
29- createCmd := newPreviewCreateCmd()
30-
31- cmd.AddCommand(
32- createCmd,
33- newPreviewListCmd(),
34- newPreviewDeleteCmd(),
35- newPreviewExtendCmd(),
36- )
37-
38- return cmd
39-}
40-
41-func newPreviewCreateCmd() *cobra.Command {
42- var (
43- ttl string
44- noBuild bool
45- noData bool
46- platform string
47- )
48-
49- cmd := &cobra.Command{
50- Use: "create [name]",
51- Short: "Create a preview environment",
52- Args: cobra.MaximumNArgs(1),
53- RunE: func(cmd *cobra.Command, args []string) error {
54- ctx := cmd.Context()
55- out := cmd.OutOrStdout()
56-
57- // Resolve preview name from arg or git branch.
58- name, err := resolvePreviewName(ctx, args)
59- if err != nil {
60- return err
61- }
62-
63- previewTag := "preview-" + name
64-
65- if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil {
66- return err
67- }
68-
69- // Resolve control plane (remote or local) — same as push.
70- handle, err := resolveControlPlane(configPath)
71- if err != nil {
72- return err
73- }
74- defer handle.close()
75-
76- previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag)
77- if err != nil {
78- return err
79- }
80-
81- if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil {
82- return err
83- }
84-
85- return waitForPreview(ctx, out, name, ttl, noData)
86- },
87- }
88-
89- cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
90- cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
91- cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
92- cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
93- return cmd
94-}
95-
96-func newPreviewListCmd() *cobra.Command {
97- var jsonOutput bool
98- cmd := &cobra.Command{
99- Use: "list",
100- Short: "List active preview environments",
101- RunE: func(cmd *cobra.Command, args []string) error {
102- ctx := cmd.Context()
103- out := cmd.OutOrStdout()
104-
105- client, isRemote, err := GetRemoteClient()
106- if err != nil {
107- return err
108- }
109- if !isRemote {
110- return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
111- }
112-
113- previews, err := client.ListPreviews(ctx)
114- if err != nil {
115- return fmt.Errorf("failed to list previews: %w", err)
116- }
117-
118- if jsonOutput {
119- return writeJSON(out, previews)
120- }
121-
122- if len(previews) == 0 {
123- return cliWriteLine(out, cliRenderEmptyState("No active previews"))
124- }
125-
126- return printPreviewTable(out, previews)
127- },
128- }
129- cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
130- return cmd
131-}
132-
133-func newPreviewDeleteCmd() *cobra.Command {
134- return &cobra.Command{
135- Use: "delete <name>",
136- Short: "Delete a preview environment",
137- Args: cobra.ExactArgs(1),
138- RunE: func(cmd *cobra.Command, args []string) error {
139- ctx := cmd.Context()
140- out := cmd.OutOrStdout()
141- name := args[0]
142-
143- client, isRemote, err := GetRemoteClient()
144- if err != nil {
145- return err
146- }
147- if !isRemote {
148- return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
149- }
150-
151- if err := client.DeletePreview(ctx, name); err != nil {
152- return fmt.Errorf("failed to delete preview %q: %w", name, err)
153- }
154-
155- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
156- },
157- }
158-}
159-
160-func newPreviewExtendCmd() *cobra.Command {
161- var ttl string
162- cmd := &cobra.Command{
163- Use: "extend <name>",
164- Short: "Extend a preview environment's TTL",
165- Args: cobra.ExactArgs(1),
166- RunE: func(cmd *cobra.Command, args []string) error {
167- ctx := cmd.Context()
168- out := cmd.OutOrStdout()
169- name := args[0]
170-
171- client, isRemote, err := GetRemoteClient()
172- if err != nil {
173- return err
174- }
175- if !isRemote {
176- return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
177- }
178-
179- if err := client.ExtendPreview(ctx, name, ttl); err != nil {
180- return fmt.Errorf("failed to extend preview %q: %w", name, err)
181- }
182-
183- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
184- },
185- }
186- cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration")
187- return cmd
188-}
189-
190-func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error {
191- w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
192- if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
193- return err
194- }
195- now := time.Now()
196- for _, p := range previews {
197- remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
198- expiresStr := remaining.String()
199- if p.IsExpired(now) {
200- expiresStr = "expired"
201- }
202- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
203- p.Name,
204- p.Domain,
205- truncateImage(p.Image, 40),
206- p.CreatedAt.Format(time.DateTime),
207- expiresStr,
208- ); err != nil {
209- return err
210- }
211- }
212- return w.Flush()
213-}
214-
215-func resolvePreviewName(ctx context.Context, args []string) (string, error) {
216- if len(args) > 0 {
217- name := preview.SanitizeBranchName(args[0])
218- if name == "" {
219- return "", fmt.Errorf("invalid preview name after sanitization: %q", args[0])
220- }
221- return name, nil
222- }
223- branch, err := detectGitBranch(ctx)
224- if err != nil {
225- return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err)
226- }
227- name := preview.SanitizeBranchName(branch)
228- if name == "" {
229- return "", fmt.Errorf("git branch %q produced empty preview name after sanitization", branch)
230- }
231- return name, nil
232-}
233-
234-func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) {
235- dockerfile := "Dockerfile"
236- imageName, err := detectImageName(dockerfile)
237- if err != nil {
238- return "", err
239- }
240- if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil {
241- return "", err
242- }
243-
244- // Resolve registry from existing routes without showing the interactive
245- // selector — preview only needs the registry URL, not a target domain.
246- registry, err := resolveRegistryForImage(ctx, cp, imageName)
247- if err != nil {
248- return "", err
249- }
250-
251- previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
252- if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil {
253- return "", err
254- }
255- return previewRef, nil
256-}
257-
258-// resolveRegistryForImage finds the registry URL from any existing non-preview
259-// route for the given image, without prompting.
260-func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
261- routes, err := cp.FindRoutesByImage(ctx, imageName)
262- if err != nil {
263- return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
264- }
265-
266- for _, r := range routes {
267- if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
268- continue
269- }
270- reg, _, _ := parseImageRef(r.Image)
271- if reg != "" {
272- return reg, nil
273- }
274- }
275-
276- return "", fmt.Errorf("no route configured for image %q", imageName)
277-}
278-
279-func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
280- imageOps, err := newImageOpsFn()
281- if err != nil {
282- return err
283- }
284-
285- if !noBuild {
286- dockerfile := "Dockerfile"
287- if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
288- return fmt.Errorf("dockerfile not found: %s", dockerfile)
289- }
290- buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef)
291- if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
292- return err
293- }
294- if err := imageOps.Build(ctx, buildArgs); err != nil {
295- return err
296- }
297- }
298-
299- if err := cliWriteLine(out, "Pushing..."); err != nil {
300- return err
301- }
302- if err := imageOps.Push(ctx, previewRef); err != nil {
303- return fmt.Errorf("failed to push %s: %w", previewRef, err)
304- }
305- return nil
306-}
307-
308-func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
309- if ttl != "" {
310- if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
311- return err
312- }
313- }
314- if noData {
315- if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil {
316- return err
317- }
318- }
319- return nil
320-}
321-
322-func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
323- if err := printPreviewFlags(out, ttl, noData); err != nil {
324- return err
325- }
326-
327- client, isRemote, err := GetRemoteClient()
328- if err != nil {
329- return err
330- }
331- if !isRemote {
332- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
333- }
334-
335- result, err := pollPreviewWithSpinner(ctx, out, client, name)
336- if err != nil {
337- return err
338- }
339-
340- switch result.Status {
341- case domain.PreviewStatusRunning:
342- scheme := "http"
343- if result.HTTPS {
344- scheme = "https"
345- }
346- previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
347- if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
348- return err
349- }
350- return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
351- case domain.PreviewStatusFailed:
352- return fmt.Errorf("preview deployment failed")
353- default:
354- return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
355- }
356-}
357-
358-type previewPollResult struct {
359- preview *domain.PreviewRoute
360- err error
361-}
362-
363-type previewPollDoneMsg previewPollResult
364-
365-type previewSpinnerModel struct {
366- spinner components.SpinnerModel
367- done <-chan previewPollResult
368- outcome previewPollResult
369- finished bool
370-}
371-
372-func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
373- return previewSpinnerModel{
374- spinner: components.NewSpinner(
375- components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
376- components.WithSpinnerType(components.SpinnerMiniDot),
377- ),
378- done: done,
379- }
380-}
381-
382-func (m previewSpinnerModel) Init() tea.Cmd {
383- return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
384-}
385-
386-func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387- switch msg := msg.(type) {
388- case previewPollDoneMsg:
389- m.outcome = previewPollResult(msg)
390- m.finished = true
391- return m, tea.Quit
392- default:
393- updated, cmd := m.spinner.Update(msg)
394- if sm, ok := updated.(components.SpinnerModel); ok {
395- m.spinner = sm
396- }
397- return m, cmd
398- }
399-}
400-
401-func (m previewSpinnerModel) View() string {
402- return m.spinner.View()
403-}
404-
405-func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
406- return func() tea.Msg {
407- return previewPollDoneMsg(<-done)
408- }
409-}
410-
411-func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
412- done := make(chan previewPollResult, 1)
413- go func() {
414- result, err := pollPreviewStatus(ctx, client, name)
415- done <- previewPollResult{preview: result, err: err}
416- }()
417-
418- if !isInteractiveTerminal() {
419- if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
420- return nil, err
421- }
422- r := <-done
423- return r.preview, r.err
424- }
425-
426- model := newPreviewSpinnerModel(name, done)
427- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
428- fmt.Print("\r\033[K")
429- if err != nil {
430- return nil, err
431- }
432-
433- m, ok := final.(previewSpinnerModel)
434- if !ok || !m.finished {
435- return nil, fmt.Errorf("preview spinner exited unexpectedly")
436- }
437- return m.outcome.preview, m.outcome.err
438-}
439-
440-func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
441- ticker := time.NewTicker(2 * time.Second)
442- defer ticker.Stop()
443- timeout := time.After(3 * time.Minute)
444-
445- for {
446- select {
447- case <-ctx.Done():
448- return nil, ctx.Err()
449- case <-timeout:
450- return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
451- case <-ticker.C:
452- p, err := client.GetPreview(ctx, name)
453- if err != nil {
454- continue
455- }
456- if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
457- return p, nil
458- }
459- }
460- }
461-}
462-
463-func detectGitBranch(ctx context.Context) (string, error) {
464- out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
465- if err != nil {
466- return "", fmt.Errorf("detect git branch: %w", err)
467- }
468- return strings.TrimSpace(string(out)), nil
469-}
A internal/adapters/in/cli/prune.go +76 -0
@@ -0,0 +1,76 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/dto"
8+ "github.com/bnema/gordon/internal/domain"
9+ "github.com/bnema/gordon/pkg/bytesize"
10+)
11+
12+// cliRenderPruneSummary writes the shared prune verdict summary: counts
13+// per verdict, what was removed, and the reasons protected or unknown
14+// candidates were skipped. Dry-run and execution use the same renderer.
15+func cliRenderPruneSummary(out io.Writer, summary dto.PruneSummary) error {
16+ action := "Deleted"
17+ if !summary.Applied {
18+ action = "Would delete"
19+ }
20+ // The wire summary carries the authoritative counts, so the human
21+ // output can never disagree with the JSON body.
22+ actionCount := len(summary.Deleted)
23+ if !summary.Applied {
24+ actionCount = summary.Eligible
25+ }
26+ if err := cliWritef(out, "%s: %d (eligible=%d protected=%d unknown=%d)\n",
27+ action, actionCount, summary.Eligible, summary.Protected, summary.Unknown); err != nil {
28+ return err
29+ }
30+ if summary.ReclaimedKnown {
31+ if err := cliWritef(out, "Space reclaimed: %s\n", bytesize.Format(summary.ReclaimedBytes)); err != nil {
32+ return err
33+ }
34+ }
35+ for _, failure := range summary.Failures {
36+ line := fmt.Sprintf("failed to delete %s %s: %s", failure.Kind, failure.Ref, failure.Error)
37+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
38+ return err
39+ }
40+ }
41+ for _, gap := range summary.Gaps {
42+ line := fmt.Sprintf("inventory incomplete (%s: %s); dependent candidates were left untouched", gap.Source, gap.Reason)
43+ if err := cliWriteLine(out, cliRenderWarning(line)); err != nil {
44+ return err
45+ }
46+ }
47+ return cliRenderSkippedCandidates(out, summary.Candidates)
48+}
49+
50+// cliRenderSkippedCandidates lists the not-deleted candidates with their
51+// first reason, so an operator can see why each resource survived.
52+func cliRenderSkippedCandidates(out io.Writer, candidates []dto.PruneCandidate) error {
53+ skipped := make([]dto.PruneCandidate, 0, len(candidates))
54+ for _, candidate := range candidates {
55+ if candidate.Verdict == string(domain.PruneVerdictEligible) {
56+ continue
57+ }
58+ skipped = append(skipped, candidate)
59+ }
60+ if len(skipped) == 0 {
61+ return nil
62+ }
63+ if err := cliWriteLine(out, cliRenderMuted("Skipped:")); err != nil {
64+ return err
65+ }
66+ for _, candidate := range skipped {
67+ reason := ""
68+ if len(candidate.Reasons) > 0 {
69+ reason = candidate.Reasons[0]
70+ }
71+ if err := cliWritef(out, " %s %s (%s) %s\n", candidate.Kind, candidate.Ref, candidate.Verdict, reason); err != nil {
72+ return err
73+ }
74+ }
75+ return nil
76+}
M internal/adapters/in/cli/push.go +70 -382
@@ -6,7 +6,6 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io"8 "io"
9- "net/http"
10 "os"9 "os"
11 "os/exec"10 "os/exec"
12 "path/filepath"11 "path/filepath"
@@ -14,11 +13,9 @@ import (
14 "strings"13 "strings"
15 "time"14 "time"
16 15
17- tea "github.com/charmbracelet/bubbletea"
18 "github.com/spf13/cobra"16 "github.com/spf13/cobra"
19 17
20 "github.com/bnema/gordon/internal/adapters/in/cli/remote"18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
22 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
23 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
24 "github.com/bnema/gordon/pkg/validation"21 "github.com/bnema/gordon/pkg/validation"
@@ -38,6 +35,7 @@ type buildConfig struct {
38 type imagePush struct {35 type imagePush struct {
39 Registry string36 Registry string
40 ImageName string37 ImageName string
38+ SourceRef string
41 Version string39 Version string
42 VersionRef string40 VersionRef string
43 LatestRef string41 LatestRef string
@@ -45,48 +43,33 @@ type imagePush struct {
45 43
46 // pushRequest holds all inputs for the push command.44 // pushRequest holds all inputs for the push command.
47 type pushRequest struct {45 type pushRequest struct {
48- ImageArg string46+ ImageArg string
49- Domain string47+ Tag string
50- Tag string48+ Build buildConfig
51- Build buildConfig
52- NoDeploy bool
53- NoConfirm bool
54 }49 }
55 50
56 func newPushCmd() *cobra.Command {51 func newPushCmd() *cobra.Command {
57 var (52 var (
58- noDeploy bool
59- noConfirm bool
60 build bool53 build bool
61 platform string54 platform string
62 tag string55 tag string
63 dockerfile string56 dockerfile string
64 buildArgs []string57 buildArgs []string
65- domainFlag string
66 )58 )
67 59
68 cmd := &cobra.Command{60 cmd := &cobra.Command{
69 Use: "push [image]",61 Use: "push [image]",
70- Short: "Tag, push, and optionally deploy an image",62+ Short: "Tag and push an image to the Gordon registry",
71 Long: `Tags a local image for the Gordon registry and pushes it.63 Long: `Tags a local image for the Gordon registry and pushes it.
72-Uses git tags for versioning. Optionally triggers deployment after push.64+Uses git tags for versioning. Push transfers OCI content only: it never
73-65+deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
74-Image-first syntax is primary:66+the manifest that references the pushed tag.
75- - Positional args resolve routes by image name first
76- - Tagged positional refs still resolve routes by image name
77- - The pushed version comes from --tag, CI tag refs, or git describe
78- - Dotted positional refs fall back to legacy domain lookup if no image route exists
79- - --domain is a deploy target override for legacy workflows
80- - No-arg mode still auto-detects from Dockerfile labels or current directory name
81-
82-With --build, builds the image first using docker buildx.
83 67
84 Examples:68 Examples:
85- gordon push myapp --build --remote ...69+ gordon images push myapp --build --remote ...
86- gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...70+ gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
87- gordon push registry.example.com/myapp:v1.2.3 --build --remote ...71+ gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
88- gordon push --domain myapp.example.com --build --remote ...72+ gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
89- gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
90 Args: cobra.MaximumNArgs(1),73 Args: cobra.MaximumNArgs(1),
91 RunE: func(cmd *cobra.Command, args []string) error {74 RunE: func(cmd *cobra.Command, args []string) error {
92 var imageArg string75 var imageArg string
@@ -94,24 +77,18 @@ Examples:
94 imageArg = args[0]77 imageArg = args[0]
95 }78 }
96 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{79 return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
97- ImageArg: imageArg,80+ ImageArg: imageArg,
98- Domain: domainFlag,81+ Tag: tag,
99- Tag: tag,82+ Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
100- Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
101- NoDeploy: noDeploy,
102- NoConfirm: noConfirm,
103 })83 })
104 },84 },
105 }85 }
106 86
107- cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
108- cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
109 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")87 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
110 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")88 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
111 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")89 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
112 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")90 cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
113 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")91 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
114- cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
115 92
116 return cmd93 return cmd
117 }94 }
@@ -141,7 +118,8 @@ const (
141 118
142 type classifiedPushArg struct {119 type classifiedPushArg struct {
143 kind pushArgKind120 kind pushArgKind
144- lookupImage string121+ sourceRef string
122+ repository string
145 legacyDomain string123 legacyDomain string
146 }124 }
147 125
@@ -150,11 +128,11 @@ func classifyPushArgument(arg string) classifiedPushArg {
150 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}128 return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
151 }129 }
152 130
153- classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}131+ _, repository, _ := parseImageRef(arg)
154- if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {132+ if repository == "" {
155- classified.lookupImage = parsedImage133+ repository, _ = validation.ParseImageReference(arg)
156 }134 }
157- return classified135+ return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
158 }136 }
159 137
160 func looksLikeLegacyDomain(arg string) bool {138 func looksLikeLegacyDomain(arg string) bool {
@@ -168,51 +146,29 @@ func looksLikeLegacyDomain(arg string) bool {
168 return strings.Contains(host, ".")146 return strings.Contains(host, ".")
169 }147 }
170 148
171-// resolveRoute determines the registry, image name, and domain from the input mode.149+// resolveImageTarget determines the registry and image name from the input.
172-func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {150+// Push transfers OCI content only and never triggers deploys or route
173- if domainFlag != "" {151+// lookups. The deploy target comes from the app manifest applied
174- return resolveFromDomain(ctx, cp, domainFlag)152+// separately via `gordon apps apply`.
175- }153+func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
176-
177 if imageArg == "" {154 if imageArg == "" {
178 imageArg, err = detectImageName(dockerfile)155 imageArg, err = detectImageName(dockerfile)
179 if err != nil {156 if err != nil {
180 return "", "", "", err157 return "", "", "", err
181 }158 }
182- fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))159+ if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
183- }
184-
185- if looksLikeLegacyDomain(imageArg) {
186- lookupImage := imageArg
187- domainLookupArg := imageArg
188- if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
189- domainLookupArg = parsedImage
190- if !strings.HasPrefix(ref, "sha256:") {
191- lookupImage = parsedImage
192- }
193- }
194-
195- registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
196- if err == nil {
197- return registry, imageName, pushDomain, nil
198- }
199- if !isImageBootstrapError(err) {
200 return "", "", "", err160 return "", "", "", err
201 }161 }
202-
203- registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
204- if err == nil {
205- return registry, imageName, pushDomain, nil
206- }
207- if errors.Is(err, domain.ErrRouteNotFound) {
208- return "", "", "", noRouteForImageError(domainLookupArg)
209- }
210- return "", "", "", err
211 }162 }
212-163+ if looksLikeLegacyDomain(imageArg) {
164+ return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165+ }
213 classified := classifyPushArgument(imageArg)166 classified := classifyPushArgument(imageArg)
214-167+ registry, imageName, _ = parseImageRef(classified.sourceRef)
215- return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)168+ if registry == "" || imageName == "" {
169+ return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
170+ }
171+ return registry, imageName, classified.sourceRef, nil
216 }172 }
217 173
218 // resolveVersion determines and validates the version tag.174 // resolveVersion determines and validates the version tag.
@@ -233,7 +189,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
233 }189 }
234 defer handle.close()190 defer handle.close()
235 191
236- registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)192+ registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
237 if err != nil {193 if err != nil {
238 return err194 return err
239 }195 }
@@ -246,7 +202,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
246 return err202 return err
247 }203 }
248 204
249- img := imagePush{Registry: registry, ImageName: imageName, Version: version}205+ img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
250 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)206 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
251 207
252 imageOps, err := newPushImageOps(inferredRemote)208 imageOps, err := newPushImageOps(inferredRemote)
@@ -262,22 +218,14 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
262 return err218 return err
263 }219 }
264 }220 }
265- if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266- return err
267- }
268-
269- skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}221 build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271- if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {222+ if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
272 return err223 return err
273 }224 }
274 225
275 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {226 if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276 return err227 return err
277 }228 }
278- if !skipExplicitDeploy {
279- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280- }
281 return nil229 return nil
282 }230 }
283 231
@@ -286,7 +234,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
286 if err != nil {234 if err != nil {
287 return "", nil, nil, err235 return "", nil, nil, err
288 }236 }
289- inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)237+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
290 if err != nil {238 if err != nil {
291 return "", nil, nil, err239 return "", nil, nil, err
292 }240 }
@@ -297,7 +245,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc
297 }245 }
298 return dockerfile, inferredRemote, handle, nil246 return dockerfile, inferredRemote, handle, nil
299 }247 }
300- handle, err = resolveControlPlane(configPath)248+ handle, err = resolveControlPlane(cliConfigPath)
301 if err != nil {249 if err != nil {
302 return "", nil, nil, err250 return "", nil, nil, err
303 }251 }
@@ -320,179 +268,15 @@ func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error
320 return newImageOpsFn()268 return newImageOpsFn()
321 }269 }
322 270
323-func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {271+func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
324 if build.Enabled {272 if build.Enabled {
325- return buildAndPush(ctx, imageOps, build, img)273+ return buildAndPush(ctx, out, imageOps, build, img)
326- }
327- return tagAndPush(ctx, imageOps, img)
328-}
329-
330-// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
331-// should be skipped (either because --no-deploy was set, or the token lacks scope).
332-func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
333- // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
334- // Without this, the server's event listener would still auto-deploy the pushed image.
335- if err := plane.DeployIntent(ctx, imageName); err != nil {
336- if isInsufficientScope(err) {
337- fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
338- return true
339- }
340- // Non-fatal: worst case we get a redundant deploy via event
341- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
342- }
343- return noDeploy
344-}
345-
346-// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
347-// meaning the token lacks the required admin scope.
348-func isInsufficientScope(err error) bool {
349- var httpErr *remote.HTTPError
350- return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
351-}
352-
353-// resolveFromDomain resolves image info from a domain name (legacy mode).
354-func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
355- route, err := cp.GetRoute(ctx, pushDomain)
356- if err != nil {
357- return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
358- }
359-
360- registry, imageName, _ = parseImageRef(route.Image)
361- if registry == "" || imageName == "" {
362- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
363- }
364-
365- return registry, imageName, pushDomain, nil
366-}
367-
368-// resolveFromImage resolves domain(s) from an image name using the backend.
369-func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
370- // First, query the backend to find routes for this image
371- routes, err := cp.FindRoutesByImage(ctx, imageArg)
372- if err != nil {
373- return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
374- }
375-
376- routes = filterPreviewRoutes(routes)
377-
378- if len(routes) == 0 {
379- return "", "", "", noRouteForImageError(imageArg)
380- }
381-
382- // Pick the target domain
383- var targetRoute domain.Route
384- if len(routes) == 1 {
385- targetRoute = routes[0]
386- } else {
387- // Multiple domains: check Dockerfile labels first, then prompt
388- targetRoute, err = selectDomain(routes, dockerfile)
389- if err != nil {
390- return "", "", "", err
391- }
392- }
393-
394- registry, imageName, _ = parseImageRef(targetRoute.Image)
395- if registry == "" || imageName == "" {
396- return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
397- }
398-
399- return registry, imageName, targetRoute.Domain, nil
400-}
401-
402-func isImageBootstrapError(err error) bool {
403- return errors.Is(err, domain.ErrNoRouteForImage)
404-}
405-
406-func noRouteForImageError(imageArg string) error {
407- // bootstrap command is registered in bootstrap.go (see issue #98)
408- return noRouteForImageBootstrapError{imageArg: imageArg}
409-}
410-
411-type noRouteForImageBootstrapError struct {
412- imageArg string
413-}
414-
415-func (e noRouteForImageBootstrapError) Error() string {
416- return fmt.Sprintf(
417- "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
418- e.imageArg,
419- e.imageArg,
420- e.imageArg,
421- e.imageArg,
422- )
423-}
424-
425-func (e noRouteForImageBootstrapError) Unwrap() error {
426- return domain.ErrNoRouteForImage
427-}
428-
429-// selectDomain picks the target domain from multiple routes.
430-// Checks Dockerfile labels first, then falls back to interactive selection.
431-func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
432- // Try to resolve from Dockerfile labels
433- labels := parseDockerfileLabels(dockerfile)
434- labelDomain := labels[domain.LabelDomain]
435- labelDomains := labels[domain.LabelDomains]
436-
437- // Collect domains from labels
438- var labelDomainList []string
439- if labelDomain != "" {
440- labelDomainList = append(labelDomainList, labelDomain)
441- }
442- if labelDomains != "" {
443- for d := range strings.SplitSeq(labelDomains, ",") {
444- d = strings.TrimSpace(d)
445- if d != "" {
446- labelDomainList = append(labelDomainList, d)
447- }
448- }
449- }
450-
451- // Try to find a matching route from labels
452- if len(labelDomainList) > 0 {
453- routeMap := make(map[string]domain.Route, len(routes))
454- for _, r := range routes {
455- routeMap[r.Domain] = r
456- }
457- for _, ld := range labelDomainList {
458- if r, ok := routeMap[ld]; ok {
459- return r, nil
460- }
461- }
462- }
463-
464- // Fall back to interactive selection
465- items := make([]string, 0, len(routes))
466- for _, r := range routes {
467- items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
468- }
469-
470- selected, err := components.RunSelector(
471- "Multiple domains found for this image. Select target:",
472- items,
473- "",
474- )
475- if err != nil {
476- return domain.Route{}, fmt.Errorf("selection error: %w", err)
477 }274 }
478- if selected == "" {275+ return tagAndPush(ctx, out, imageOps, img)
479- return domain.Route{}, fmt.Errorf("no domain selected")
480- }
481-
482- // Extract domain from the selected display string
483- for i, item := range items {
484- if item == selected {
485- return routes[i], nil
486- }
487- }
488-
489- return domain.Route{}, fmt.Errorf("selected domain not found")
490 }276 }
491 277
492-// detectImageName auto-detects the image name from context.278+// detectImageName resolves the image name from Dockerfile labels or the
493-// Resolution order:279+// current directory name.
494-// 1. Dockerfile label gordon.domain (if Dockerfile exists)
495-// 2. Current directory name
496 func detectImageName(dockerfile string) (string, error) {280 func detectImageName(dockerfile string) (string, error) {
497 // Try Dockerfile labels281 // Try Dockerfile labels
498 labels := parseDockerfileLabels(dockerfile)282 labels := parseDockerfileLabels(dockerfile)
@@ -513,7 +297,7 @@ func detectImageName(dockerfile string) (string, error) {
513 297
514 dirName := filepath.Base(cwd)298 dirName := filepath.Base(cwd)
515 if dirName == "." || dirName == "/" {299 if dirName == "." || dirName == "/" {
516- return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")300+ return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
517 }301 }
518 302
519 return dirName, nil303 return dirName, nil
@@ -655,7 +439,7 @@ func parseTagRef(ref string) string {
655 return tag439 return tag
656 }440 }
657 441
658-func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {442+func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
659 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {443 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
660 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)444 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
661 }445 }
@@ -663,12 +447,16 @@ func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img
663 // Build and load into local daemon (NOT --push).447 // Build and load into local daemon (NOT --push).
664 // The native registry push client handles chunked uploads to stay448 // The native registry push client handles chunked uploads to stay
665 // within Cloudflare's 100MB per-request limit.449 // within Cloudflare's 100MB per-request limit.
666- fmt.Println("\nBuilding image...")450+ if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
451+ return err
452+ }
667 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {453 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
668 return err454 return err
669 }455 }
670 456
671- fmt.Println("Pushing...")457+ if err := cliWriteLine(out, "Pushing..."); err != nil {
458+ return err
459+ }
672 if err := ops.Push(ctx, img.LatestRef); err != nil {460 if err := ops.Push(ctx, img.LatestRef); err != nil {
673 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)461 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
674 }462 }
@@ -731,10 +519,15 @@ func buildImageArgs(ctx context.Context, version, platform, dockerfile string, b
731 return args519 return args
732 }520 }
733 521
734-func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {522+func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
735- localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)523+ localImage := img.SourceRef
524+ if localImage == "" {
525+ return errors.New("push source image reference cannot be empty")
526+ }
736 527
737- fmt.Println("\nChecking local image...")528+ if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
529+ return err
530+ }
738 exists, err := ops.Exists(ctx, localImage)531 exists, err := ops.Exists(ctx, localImage)
739 if err != nil {532 if err != nil {
740 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)533 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
@@ -743,7 +536,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
743 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)536 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
744 }537 }
745 538
746- fmt.Println("Tagging...")539+ if err := cliWriteLine(out, "Tagging..."); err != nil {
540+ return err
541+ }
747 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {542 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
748 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)543 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
749 }544 }
@@ -753,7 +548,9 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
753 }548 }
754 }549 }
755 550
756- fmt.Println("Pushing...")551+ if err := cliWriteLine(out, "Pushing..."); err != nil {
552+ return err
553+ }
757 if err := ops.Push(ctx, img.VersionRef); err != nil {554 if err := ops.Push(ctx, img.VersionRef); err != nil {
758 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)555 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
759 }556 }
@@ -765,118 +562,6 @@ func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
765 return nil562 return nil
766 }563 }
767 564
768-func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
769- if !noConfirm {
770- confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
771- if err != nil {
772- return err
773- }
774- if !confirmed {
775- return nil
776- }
777- }
778-
779- var (
780- result *remote.DeployResult
781- err error
782- )
783- if remoteCP, ok := cp.(*remoteControlPlane); ok {
784- result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
785- } else {
786- result, err = cp.Deploy(ctx, pushDomain)
787- }
788- if err != nil {
789- return formatDeployFailure(err)
790- }
791- containerID := shortContainerID(result.ContainerID)
792- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
793- return nil
794-}
795-
796-func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
797- if !isInteractiveTerminal() {
798- fmt.Printf("Deploying %s...\n", pushDomain)
799- return client.Deploy(ctx, pushDomain)
800- }
801-
802- done := make(chan deployOutcome, 1)
803- go func() {
804- result, err := client.Deploy(ctx, pushDomain)
805- done <- deployOutcome{result: result, err: err}
806- }()
807-
808- model := newDeploySpinnerModel(pushDomain, done)
809- final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
810- fmt.Print("\r\033[K")
811- if err != nil {
812- return nil, err
813- }
814-
815- deployModel, ok := final.(deploySpinnerModel)
816- if !ok {
817- return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
818- }
819- if !deployModel.finished {
820- return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
821- }
822-
823- return deployModel.outcome.result, deployModel.outcome.err
824-}
825-
826-type deployOutcome struct {
827- result *remote.DeployResult
828- err error
829-}
830-
831-type deployDoneMsg deployOutcome
832-
833-type deploySpinnerModel struct {
834- spinner components.SpinnerModel
835- done <-chan deployOutcome
836- outcome deployOutcome
837- finished bool
838-}
839-
840-func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
841- return deploySpinnerModel{
842- spinner: components.NewSpinner(
843- components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
844- components.WithSpinnerType(components.SpinnerMiniDot),
845- ),
846- done: done,
847- }
848-}
849-
850-func (m deploySpinnerModel) Init() tea.Cmd {
851- return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
852-}
853-
854-func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
855- switch msg := msg.(type) {
856- case deployDoneMsg:
857- m.outcome = deployOutcome(msg)
858- m.finished = true
859- return m, tea.Quit
860- default:
861- updated, cmd := m.spinner.Update(msg)
862- spinnerModel, ok := updated.(components.SpinnerModel)
863- if ok {
864- m.spinner = spinnerModel
865- }
866- return m, cmd
867- }
868-}
869-
870-func (m deploySpinnerModel) View() string {
871- return m.spinner.View()
872-}
873-
874-func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
875- return func() tea.Msg {
876- return deployDoneMsg(<-done)
877- }
878-}
879-
880 func isInteractiveTerminal() bool {565 func isInteractiveTerminal() bool {
881 term := os.Getenv("TERM")566 term := os.Getenv("TERM")
882 if term == "" || term == "dumb" {567 if term == "" || term == "dumb" {
@@ -898,6 +583,9 @@ func parseImageRef(image string) (registry, name, tag string) {
898 }583 }
899 registry = parts[0]584 registry = parts[0]
900 nameTag := parts[1]585 nameTag := parts[1]
586+ if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
587+ return registry, nameTag[:idx], nameTag[idx+1:]
588+ }
901 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {589 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
902 name = nameTag[:idx]590 name = nameTag[:idx]
903 tag = nameTag[idx+1:]591 tag = nameTag[idx+1:]
A internal/adapters/in/cli/push_behavior_test.go +61 -0
@@ -0,0 +1,61 @@
1+package cli
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type recordingPushOps struct {
12+ existsRef string
13+ tags [][2]string
14+ pushes []string
15+ exists bool
16+}
17+
18+func (o *recordingPushOps) Exists(_ context.Context, ref string) (bool, error) {
19+ o.existsRef = ref
20+ return o.exists, nil
21+}
22+
23+func (o *recordingPushOps) Tag(_ context.Context, source, target string) error {
24+ o.tags = append(o.tags, [2]string{source, target})
25+ return nil
26+}
27+
28+func (o *recordingPushOps) Push(_ context.Context, ref string) error {
29+ o.pushes = append(o.pushes, ref)
30+ return nil
31+}
32+
33+func (*recordingPushOps) Build(context.Context, []string) error { return nil }
34+
35+func TestTagAndPushPreservesExactSourceReference(t *testing.T) {
36+ ops := &recordingPushOps{exists: true}
37+ img := imagePush{
38+ SourceRef: "registry.example.com/team/app:v3",
39+ Version: "v4", VersionRef: "registry.example.com/team/app:v4",
40+ LatestRef: "registry.example.com/team/app:latest",
41+ }
42+
43+ require.NoError(t, tagAndPush(context.Background(), io.Discard, ops, img))
44+ require.Equal(t, img.SourceRef, ops.existsRef)
45+ require.Equal(t, [][2]string{
46+ {img.SourceRef, img.VersionRef},
47+ {img.SourceRef, img.LatestRef},
48+ }, ops.tags)
49+ require.Equal(t, []string{img.VersionRef, img.LatestRef}, ops.pushes)
50+}
51+
52+func TestTagAndPushRejectsMissingSourceReference(t *testing.T) {
53+ ops := &recordingPushOps{exists: true}
54+ err := tagAndPush(context.Background(), io.Discard, ops, imagePush{
55+ Registry: "registry.example.com", ImageName: "team/app", Version: "v4",
56+ })
57+ require.EqualError(t, err, "push source image reference cannot be empty")
58+ require.Empty(t, ops.existsRef)
59+ require.Empty(t, ops.tags)
60+ require.Empty(t, ops.pushes)
61+}
M internal/adapters/in/cli/push_image_ops.go +9 -3
@@ -35,6 +35,7 @@ type pushImageOps interface {
35 type dockerImageOps struct {35 type dockerImageOps struct {
36 cli client.APIClient36 cli client.APIClient
37 insecureTLS bool37 insecureTLS bool
38+ plainHTTP bool
38 progress io.Writer39 progress io.Writer
39 remoteClient *remote.Client // nil when local40 remoteClient *remote.Client // nil when local
40 }41 }
@@ -70,8 +71,11 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
70 if err != nil {71 if err != nil {
71 return nil, err72 return nil, err
72 }73 }
73- if resolved != nil && resolved.Token != "" {74+ if resolved != nil {
74- ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)75+ ops.plainHTTP = strings.HasPrefix(strings.ToLower(resolved.URL), "http://")
76+ if resolved.Token != "" {
77+ ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
78+ }
75 }79 }
76 return ops, nil80 return ops, nil
77 }81 }
@@ -87,6 +91,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
87 opts := []registrypush.Option{91 opts := []registrypush.Option{
88 registrypush.WithProgress(d.progress),92 registrypush.WithProgress(d.progress),
89 registrypush.WithInsecureTLS(d.insecureTLS),93 registrypush.WithInsecureTLS(d.insecureTLS),
94+ registrypush.WithPlainHTTP(d.plainHTTP),
90 }95 }
91 96
92 if d.remoteClient != nil {97 if d.remoteClient != nil {
@@ -107,6 +112,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
107 retryOpts := []registrypush.Option{112 retryOpts := []registrypush.Option{
108 registrypush.WithProgress(d.progress),113 registrypush.WithProgress(d.progress),
109 registrypush.WithInsecureTLS(d.insecureTLS),114 registrypush.WithInsecureTLS(d.insecureTLS),
115+ registrypush.WithPlainHTTP(d.plainHTTP),
110 registrypush.WithAuth(authHeader),116 registrypush.WithAuth(authHeader),
111 }117 }
112 pusher = registrypush.New(retryOpts...)118 pusher = registrypush.New(retryOpts...)
@@ -175,7 +181,7 @@ func (d *dockerImageOps) Build(ctx context.Context, args []string) error {
175 "Podman and other runtimes are not supported for --build.\n" +181 "Podman and other runtimes are not supported for --build.\n" +
176 "Build the image manually and push with:\n" +182 "Build the image manually and push with:\n" +
177 " podman build -t <image> .\n" +183 " podman build -t <image> .\n" +
178- " gordon push <image>",184+ " gordon images push <image>",
179 )185 )
180 }186 }
181 cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204187 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
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
8 "strings"9 "strings"
@@ -33,9 +34,13 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
33 img, err := random.Image(512, 1)34 img, err := random.Image(512, 1)
34 require.NoError(t, err)35 require.NoError(t, err)
35 36
37+ registry := strings.TrimPrefix(harness.server.URL, "http://")
38+ versionRef := registry + "/testapp:v1.0.0"
39+ latestRef := registry + "/testapp:latest"
40+
36 ops := climocks.NewMockpushImageOps(t)41 ops := climocks.NewMockpushImageOps(t)
37 ops.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)42 ops.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil)
38- ops.On("Exists", mock.Anything, mock.Anything).Return(true, nil)43+ ops.On("Exists", mock.Anything, registry+"/testapp:v1.0.0").Return(true, nil)
39 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {44 ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error {
40 pusher := registrypush.New(45 pusher := registrypush.New(
41 registrypush.WithChunkSize(256),46 registrypush.WithChunkSize(256),
@@ -47,13 +52,10 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
47 return pusher.Push(ctx, ref)52 return pusher.Push(ctx, ref)
48 })53 })
49 54
50- registry := strings.TrimPrefix(harness.server.URL, "http://")55+ err = tagAndPush(ctx, io.Discard, ops, imagePush{
51- versionRef := registry + "/testapp:v1.0.0"
52- latestRef := registry + "/testapp:latest"
53-
54- err = tagAndPush(ctx, ops, imagePush{
55 Registry: registry,56 Registry: registry,
56 ImageName: "testapp",57 ImageName: "testapp",
58+ SourceRef: versionRef,
57 Version: "v1.0.0",59 Version: "v1.0.0",
58 VersionRef: versionRef,60 VersionRef: versionRef,
59 LatestRef: latestRef,61 LatestRef: latestRef,
M internal/adapters/in/cli/push_remote_inference_test.go +19 -414
@@ -13,16 +13,14 @@ import (
13 13
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16-
17- "github.com/bnema/gordon/internal/domain"
18 )16 )
19 17
20 func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {18 func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {19+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
22 assert.Equal(t, "myapp", image)20 assert.Equal(t, "myapp", image)
23- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK21+ return []string{"latest"}, http.StatusOK
24 })22 })
25- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {23+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
26 assert.Equal(t, "myapp", image)24 assert.Equal(t, "myapp", image)
27 return nil, http.StatusOK25 return nil, http.StatusOK
28 })26 })
@@ -43,11 +41,11 @@ url = "`+staging.URL+`"
43 }41 }
44 42
45 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {43 func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {44+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
47- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK45+ return []string{"latest"}, http.StatusOK
48 })46 })
49- staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {47+ staging := newTagsTestServer(t, func(image string) ([]string, int) {
50- return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK48+ return []string{"latest"}, http.StatusOK
51 })49 })
52 50
53 configurePushRemoteInferenceTestEnv(t, `51 configurePushRemoteInferenceTestEnv(t, `
@@ -73,9 +71,9 @@ func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
73 })71 })
74 72
75 called := false73 called := false
76- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {74+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
77 called = true75 called = true
78- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK76+ return []string{"latest"}, http.StatusOK
79 })77 })
80 78
81 configurePushRemoteInferenceTestEnv(t, `79 configurePushRemoteInferenceTestEnv(t, `
@@ -93,9 +91,9 @@ url = "`+prod.URL+`"
93 91
94 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {92 func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95 called := false93 called := false
96- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {94+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
97 called = true95 called = true
98- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK96+ return []string{"latest"}, http.StatusOK
99 })97 })
100 98
101 configurePushRemoteInferenceTestEnv(t, `99 configurePushRemoteInferenceTestEnv(t, `
@@ -111,26 +109,11 @@ url = "`+prod.URL+`"
111 assert.False(t, called)109 assert.False(t, called)
112 }110 }
113 111
114-func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116- return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117- })
118-
119- configurePushRemoteInferenceTestEnv(t, `
120-[remotes.prod]
121-url = "`+prod.URL+`"
122-`)
123-
124- resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125- require.NoError(t, err)
126- assert.Nil(t, resolved)
127-}
128-
129 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {112 func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130- prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {113+ prod := newTagsTestServer(t, func(image string) ([]string, int) {
131- return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK114+ return []string{"latest"}, http.StatusOK
132 })115 })
133- broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {116+ broken := newTagsTestServer(t, func(image string) ([]string, int) {
134 return nil, http.StatusInternalServerError117 return nil, http.StatusInternalServerError
135 })118 })
136 119
@@ -149,199 +132,6 @@ url = "`+broken.URL+`"
149 assert.Contains(t, err.Error(), "broken")132 assert.Contains(t, err.Error(), "broken")
150 }133 }
151 134
152-func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155- })
156- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157- return nil, http.StatusNotFound
158- })
159-
160- configurePushRemoteInferenceTestEnv(t, `
161-[remotes.prod]
162-url = "`+prod.URL+`"
163-
164-[remotes.staging]
165-url = "`+staging.URL+`"
166-`)
167-
168- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169- require.NoError(t, err)
170- require.NotNil(t, resolved)
171- assert.Equal(t, "prod", resolved.Name)
172-}
173-
174-func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175- prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177- })
178- staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180- })
181-
182- configurePushRemoteInferenceTestEnv(t, `
183-[remotes.prod]
184-url = "`+prod.URL+`"
185-
186-[remotes.staging]
187-url = "`+staging.URL+`"
188-`)
189-
190- resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191- require.Error(t, err)
192- assert.Nil(t, resolved)
193- assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194-}
195-
196-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198- getRoute: func(domainName string) (*domain.Route, int) {
199- return nil, http.StatusNotFound
200- },
201- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202- return &domain.CleanupReport{
203- Domain: domainName,
204- OrphanedEntities: []domain.CleanupOrphanedEntity{{
205- Kind: "route_container",
206- ID: "abc123",
207- Status: "running",
208- }},
209- }, http.StatusOK
210- },
211- })
212- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213- getRoute: func(domainName string) (*domain.Route, int) {
214- return nil, http.StatusNotFound
215- },
216- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218- },
219- })
220-
221- configurePushRemoteInferenceTestEnv(t, `
222-[remotes.prod]
223-url = "`+prod.URL+`"
224-
225-[remotes.staging]
226-url = "`+staging.URL+`"
227-`)
228-
229- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230- require.NoError(t, err)
231- require.NotNil(t, resolved)
232- assert.Equal(t, "prod", resolved.Name)
233-}
234-
235-func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237- getRoute: func(domainName string) (*domain.Route, int) {
238- return nil, http.StatusNotFound
239- },
240- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241- return &domain.CleanupReport{
242- Domain: domainName,
243- PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244- }, http.StatusOK
245- },
246- })
247- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248- getRoute: func(domainName string) (*domain.Route, int) {
249- return nil, http.StatusNotFound
250- },
251- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252- return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253- },
254- })
255-
256- configurePushRemoteInferenceTestEnv(t, `
257-[remotes.prod]
258-url = "`+prod.URL+`"
259-
260-[remotes.staging]
261-url = "`+staging.URL+`"
262-`)
263-
264- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265- require.NoError(t, err)
266- require.NotNil(t, resolved)
267- assert.Equal(t, "prod", resolved.Name)
268-}
269-
270-func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272- getRoute: func(domainName string) (*domain.Route, int) {
273- return nil, http.StatusNotFound
274- },
275- getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276- return nil, http.StatusForbidden
277- },
278- })
279-
280- configurePushRemoteInferenceTestEnv(t, `
281-[remotes.prod]
282-url = "`+prod.URL+`"
283-`)
284-
285- resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286- require.Error(t, err)
287- assert.Nil(t, resolved)
288- assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289-}
290-
291-func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
292- prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
293- return []string{"app.example.com"}, http.StatusOK
294- })
295- staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
296- return nil, http.StatusOK
297- })
298-
299- configurePushRemoteInferenceTestEnv(t, `
300-[remotes.prod]
301-url = "`+prod.URL+`"
302-
303-[remotes.staging]
304-url = "`+staging.URL+`"
305-`)
306-
307- resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
308- require.NoError(t, err)
309- require.NotNil(t, resolved)
310- assert.Equal(t, "prod", resolved.Name)
311-}
312-
313-func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
314- prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
315- getRoute: func(domainName string) (*domain.Route, int) {
316- return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
317- },
318- getAttachmentsConfig: func(target string) ([]string, int) {
319- return nil, http.StatusNotFound
320- },
321- })
322- staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
323- getRoute: func(domainName string) (*domain.Route, int) {
324- return nil, http.StatusNotFound
325- },
326- getAttachmentsConfig: func(target string) ([]string, int) {
327- return nil, http.StatusNotFound
328- },
329- })
330-
331- configurePushRemoteInferenceTestEnv(t, `
332-[remotes.prod]
333-url = "`+prod.URL+`"
334-
335-[remotes.staging]
336-url = "`+staging.URL+`"
337-`)
338-
339- resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
340- require.NoError(t, err)
341- require.NotNil(t, resolved)
342- assert.Equal(t, "prod", resolved.Name)
343-}
344-
345 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {135 func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346 prod := newTagsTestServer(t, func(repository string) ([]string, int) {136 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347 return []string{"latest", "v1.0.0"}, http.StatusOK137 return []string{"latest", "v1.0.0"}, http.StatusOK
@@ -386,205 +176,20 @@ func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
386 176
387 configHome := t.TempDir()177 configHome := t.TempDir()
388 t.Setenv("XDG_CONFIG_HOME", configHome)178 t.Setenv("XDG_CONFIG_HOME", configHome)
389- configPath := filepath.Join(configHome, "gordon", "remotes.toml")179+ cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml")
390- require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))180+ require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755))
391- require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))181+ require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392 }182 }
393 183
394-func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {184+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
395- t.Helper()
396-
397- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398- if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
399- http.NotFound(w, r)
400- return
401- }
402-
403- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
404- if !assert.NoError(t, err) {
405- return
406- }
407-
408- routes, status := handler(image)
409- if status >= http.StatusBadRequest {
410- w.Header().Set("Content-Type", "application/json")
411- w.WriteHeader(status)
412- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
413- return
414- }
415- return
416- }
417-
418- w.Header().Set("Content-Type", "application/json")
419- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
420- "image": image,
421- "routes": routes,
422- })) {
423- return
424- }
425- }))
426- t.Cleanup(server.Close)
427- return server
428-}
429-
430-func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
431- t.Helper()
432- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
433-}
434-
435-type multiAdminProbeHandlers struct {
436- getRoute func(domainName string) (*domain.Route, int)
437- getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
438- getAttachmentsConfig func(target string) ([]string, int)
439- listTags func(repository string) ([]string, int)
440- findAttachmentTarget func(image string) ([]string, int)
441-}
442-
443-func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
444 t.Helper()185 t.Helper()
445-
446 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {186 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447- switch {187+ handleListTagsProbe(t, w, r, handler)
448- case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449- handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
450- case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
451- handleGetRouteProbe(t, w, r, handlers.getRoute)
452- case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
453- handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
454- case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
455- handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
456- case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
457- handleListTagsProbe(t, w, r, handlers.listTags)
458- default:
459- http.NotFound(w, r)
460- }
461 }))188 }))
462 t.Cleanup(server.Close)189 t.Cleanup(server.Close)
463 return server190 return server
464 }191 }
465 192
466-func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
467- t.Helper()
468- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
469-}
470-
471-func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
472- t.Helper()
473- return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
474-}
475-
476-func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
477- t.Helper()
478- if handler == nil {
479- http.NotFound(w, r)
480- return
481- }
482- domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
483- if !assert.NoError(t, err) {
484- return
485- }
486- route, status := handler(domainName)
487- if status >= http.StatusBadRequest {
488- w.Header().Set("Content-Type", "application/json")
489- w.WriteHeader(status)
490- if status == http.StatusNotFound {
491- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
492- return
493- }
494- return
495- }
496- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
497- return
498- }
499- return
500- }
501- w.Header().Set("Content-Type", "application/json")
502- if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
503- return
504- }
505-}
506-
507-func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508- t.Helper()
509- if handler == nil {
510- http.NotFound(w, r)
511- return
512- }
513- domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514- if !assert.NoError(t, err) {
515- return
516- }
517- report, status := handler(domainName)
518- if status >= http.StatusBadRequest {
519- w.Header().Set("Content-Type", "application/json")
520- w.WriteHeader(status)
521- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522- return
523- }
524- return
525- }
526- w.Header().Set("Content-Type", "application/json")
527- if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528- return
529- }
530-}
531-
532-func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
533- t.Helper()
534- if handler == nil {
535- http.NotFound(w, r)
536- return
537- }
538- image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
539- if !assert.NoError(t, err) {
540- return
541- }
542- targets, status := handler(image)
543- if status >= http.StatusBadRequest {
544- w.Header().Set("Content-Type", "application/json")
545- w.WriteHeader(status)
546- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
547- return
548- }
549- return
550- }
551- w.Header().Set("Content-Type", "application/json")
552- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
553- return
554- }
555-}
556-
557-func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
558- t.Helper()
559- if handler == nil {
560- http.NotFound(w, r)
561- return
562- }
563- target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
564- if !assert.NoError(t, err) {
565- return
566- }
567- images, status := handler(target)
568- if status >= http.StatusBadRequest {
569- w.Header().Set("Content-Type", "application/json")
570- w.WriteHeader(status)
571- if status == http.StatusNotFound {
572- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
573- return
574- }
575- return
576- }
577- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
578- return
579- }
580- return
581- }
582- w.Header().Set("Content-Type", "application/json")
583- if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
584- return
585- }
586-}
587-
588 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {193 func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589 t.Helper()194 t.Helper()
590 if handler == nil {195 if handler == nil {
M internal/adapters/in/cli/push_test.go +5 -110 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.
296 296
297 Examples:297 Examples:
298 gordon remotes use prod298 gordon remotes use prod
299- gordon routes list # Uses prod remote automatically`,299+ gordon daemon status # Uses prod remote automatically`,
300 Args: cobra.ExactArgs(1),300 Args: cobra.ExactArgs(1),
301 RunE: func(cmd *cobra.Command, args []string) error {301 RunE: func(cmd *cobra.Command, args []string) error {
302 name := args[0]302 name := args[0]
D internal/adapters/in/cli/restart.go +0 -58 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 (
10 10
11 // newServeCmd creates the serve command.11 // newServeCmd creates the serve command.
12 func newServeCmd() *cobra.Command {12 func newServeCmd() *cobra.Command {
13- var configPath string13+ var cliConfigPath string
14 14
15 cmd := &cobra.Command{15 cmd := &cobra.Command{
16 Use: "serve",16 Use: "serve",
17 Short: "Start the Gordon server",17 Short: "Start the Gordon server",
18 Long: `Start the Gordon server, including the registry and proxy components.`,18 Long: `Start the Gordon server, including the registry and proxy components.`,
19 RunE: func(cmd *cobra.Command, args []string) error {19 RunE: func(cmd *cobra.Command, args []string) error {
20- return app.Run(context.Background(), configPath)20+ return app.Run(context.Background(), cliConfigPath)
21 },21 },
22 }22 }
23 23
24 // Add flags24 // Add flags
25- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")25+ cmd.Flags().StringVarP(&cliConfigPath, "config", "c", "", "Path to config file")
26 26
27 return cmd27 return cmd
28 }28 }
A internal/adapters/in/cli/status.go +89 -0 collapsed to keep the page light
M internal/adapters/in/cli/tls.go +2 -13
@@ -16,24 +16,13 @@ import (
16 var tlsResolveControlPlane = resolveControlPlane16 var tlsResolveControlPlane = resolveControlPlane
17 17
18 func newTLSCmd() *cobra.Command {18 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 {
30 var jsonOut bool19 var jsonOut bool
31 20
32 cmd := &cobra.Command{21 cmd := &cobra.Command{
33- Use: "status",22+ Use: "tls",
34 Short: "Show public TLS certificate status",23 Short: "Show public TLS certificate status",
35 RunE: func(cmd *cobra.Command, _ []string) error {24 RunE: func(cmd *cobra.Command, _ []string) error {
36- handle, err := tlsResolveControlPlane(configPath)25+ handle, err := tlsResolveControlPlane(cliConfigPath)
37 if err != nil {26 if err != nil {
38 return err27 return err
39 }28 }
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