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 Unverified
B5690EEEBB952194The 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 | 5 | branches: [main] | |
| 6 | 6 | pull_request: | |
| 7 | 7 | ||
| 8 | + | permissions: | |
| 9 | + | contents: read | |
| 10 | + | ||
| 8 | 11 | jobs: | |
| 9 | 12 | changes: | |
| 10 | 13 | name: Detect changes | |
| @@ -13,6 +16,7 @@ jobs: | |||
| 13 | 16 | pull-requests: read | |
| 14 | 17 | outputs: | |
| 15 | 18 | go: ${{ steps.filter.outputs.go }} | |
| 19 | + | installer: ${{ steps.filter.outputs.installer }} | |
| 16 | 20 | steps: | |
| 17 | 21 | - uses: actions/checkout@v6 | |
| 18 | 22 | - uses: dorny/paths-filter@v3 | |
| @@ -26,6 +30,22 @@ jobs: | |||
| 26 | 30 | - '.golangci.yml' | |
| 27 | 31 | - 'Makefile' | |
| 28 | 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 | 50 | lint: | |
| 31 | 51 | name: Lint | |
| @@ -38,14 +58,7 @@ jobs: | |||
| 38 | 58 | - uses: actions/setup-go@v5 | |
| 39 | 59 | with: | |
| 40 | 60 | go-version: "1.27.x" | |
| 41 | - | ||
| 42 | - | - name: Cache Go modules | |
| 43 | - | uses: actions/cache@v5 | |
| 44 | - | with: | |
| 45 | - | path: ~/go/pkg/mod | |
| 46 | - | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} | |
| 47 | - | restore-keys: | | |
| 48 | - | ${{ runner.os }}-go-mod- | |
| 61 | + | cache: true | |
| 49 | 62 | ||
| 50 | 63 | - name: Run golangci-lint | |
| 51 | 64 | uses: golangci/golangci-lint-action@v9 | |
| @@ -63,22 +76,7 @@ jobs: | |||
| 63 | 76 | - uses: actions/setup-go@v5 | |
| 64 | 77 | with: | |
| 65 | 78 | go-version: "1.27.x" | |
| 66 | - | ||
| 67 | - | - name: Cache Go modules | |
| 68 | - | uses: actions/cache@v5 | |
| 69 | - | with: | |
| 70 | - | path: ~/go/pkg/mod | |
| 71 | - | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} | |
| 72 | - | restore-keys: | | |
| 73 | - | ${{ runner.os }}-go-mod- | |
| 74 | - | ||
| 75 | - | - name: Cache Go build cache | |
| 76 | - | uses: actions/cache@v5 | |
| 77 | - | with: | |
| 78 | - | path: ~/.cache/go-build | |
| 79 | - | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 80 | - | restore-keys: | | |
| 81 | - | ${{ runner.os }}-go-build- | |
| 79 | + | cache: true | |
| 82 | 80 | ||
| 83 | 81 | - name: Run tests | |
| 84 | 82 | run: go test -race -v ./... | |
M .goreleaser.yaml +1 -1
| @@ -23,7 +23,7 @@ builds: | |||
| 23 | 23 | env: | |
| 24 | 24 | - CGO_ENABLED=0 | |
| 25 | 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 | 28 | # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup) | |
| 29 | 29 | # dockers: | |
M .mockery.yaml +14 -6
| @@ -17,20 +17,28 @@ packages: | |||
| 17 | 17 | EventSubscriber: | |
| 18 | 18 | EventHandler: | |
| 19 | 19 | SecretProvider: | |
| 20 | - | EnvLoader: | |
| 21 | 20 | ContainerLogWriter: | |
| 22 | - | AttachmentConfigProvider: | |
| 21 | + | Metrics: | |
| 22 | + | LogExporter: | |
| 23 | + | ContainerLogStreamer: | |
| 23 | 24 | TokenStore: | |
| 24 | - | DomainSecretStore: | |
| 25 | + | AppState: | |
| 26 | + | AppStateReader: | |
| 27 | + | ImageResolver: | |
| 28 | + | SecretWriter: | |
| 25 | 29 | RateLimiter: | |
| 26 | 30 | BackupStorage: | |
| 27 | - | RouteChecker: | |
| 28 | 31 | HTTPChallengeSink: | |
| 29 | 32 | PublicCertificateIssuer: | |
| 30 | 33 | CertificateStore: | |
| 31 | 34 | SecretResolver: | |
| 32 | 35 | CloudflareZoneResolver: | |
| 33 | 36 | CertificateAuthority: | |
| 37 | + | AppTrafficRefresher: | |
| 38 | + | PruneProtectionStore: | |
| 39 | + | PruneRuntime: | |
| 40 | + | GCBarrier: | |
| 41 | + | GCLease: | |
| 34 | 42 | github.com/bnema/gordon/internal/boundaries/in: | |
| 35 | 43 | interfaces: | |
| 36 | 44 | ContainerService: | |
| @@ -42,12 +50,12 @@ packages: | |||
| 42 | 50 | AuthService: | |
| 43 | 51 | HealthService: | |
| 44 | 52 | HTTPProber: | |
| 45 | - | SecretService: | |
| 46 | 53 | LogService: | |
| 47 | 54 | VolumeService: | |
| 48 | 55 | PublicTLSService: | |
| 49 | 56 | TrafficStatusService: | |
| 50 | - | StandaloneServiceService: | |
| 57 | + | AppService: | |
| 58 | + | AppReconciler: | |
| 51 | 59 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | |
| 52 | 60 | # Mocked here because it abstracts Docker SDK calls that require a running | |
| 53 | 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 | 112 | ### ControlPlane Pattern | |
| 113 | 113 | ||
| 114 | - | `ControlPlane` interface (`controlplane.go`) abstracts local vs remote operations. | |
| 115 | - | - `controlplane_remote.go` — delegates to `remote.Client` HTTP methods. | |
| 116 | - | - `controlplane_local.go` — calls service interfaces directly. | |
| 117 | - | - Test fakes in `push_test.go` — update when adding interface methods. | |
| 114 | + | `ControlPlane` interface (`controlplane.go`) is the seam CLI commands depend on. | |
| 115 | + | - `*remote.Client` implements it for both the explicit remote and the local admin socket. | |
| 116 | + | - Tests use the mockery mock in `cli/mocks/` — run `mockery` after adding interface methods. | |
| 118 | 117 | ||
| 119 | 118 | ### HTTP Admin Handlers | |
| 120 | 119 | ||
M Dockerfile +7 -1
| @@ -5,6 +5,11 @@ ARG BUILDPLATFORM | |||
| 5 | 5 | ARG VERSION=dev | |
| 6 | 6 | ARG COMMIT=unknown | |
| 7 | 7 | 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 | 14 | FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder | |
| 10 | 15 | ||
| @@ -13,6 +18,7 @@ ARG TARGETARCH=amd64 | |||
| 13 | 18 | ARG VERSION | |
| 14 | 19 | ARG COMMIT | |
| 15 | 20 | ARG BUILD_DATE | |
| 21 | + | ARG DIRTY | |
| 16 | 22 | ||
| 17 | 23 | RUN apk add --no-cache git ca-certificates tzdata | |
| 18 | 24 | ||
| @@ -25,7 +31,7 @@ COPY . . | |||
| 25 | 31 | ||
| 26 | 32 | RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \ | |
| 27 | 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 | 35 | -o /gordon ./main.go | |
| 30 | 36 | ||
| 31 | 37 | FROM alpine:3.22 | |
M Makefile +8 -2
| @@ -10,12 +10,17 @@ ENGINE := podman | |||
| 10 | 10 | VERSION := $(shell git describe --tags --always --dirty) | |
| 11 | 11 | COMMIT := $(shell git rev-parse --short HEAD) | |
| 12 | 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 | 18 | # Build flags | |
| 15 | 19 | LDFLAGS := -s -w \ | |
| 16 | 20 | -X main.version=$(VERSION) \ | |
| 17 | 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 | 25 | # Architectures | |
| 21 | 26 | ARCHS := amd64 arm64 | |
| @@ -83,7 +88,7 @@ build: ## Build binaries for linux (amd64 and arm64) | |||
| 83 | 88 | @echo "Building Go binaries..." | |
| 84 | 89 | @mkdir -p $(DIST_DIR) | |
| 85 | 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 | 92 | @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go | |
| 88 | 93 | @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go | |
| 89 | 94 | @echo "Go binaries built successfully" | |
| @@ -105,6 +110,7 @@ build-push: ## Build and push Docker images | |||
| 105 | 110 | --build-arg VERSION="$(VERSION)" \ | |
| 106 | 111 | --build-arg COMMIT="$(COMMIT)" \ | |
| 107 | 112 | --build-arg BUILD_DATE="$(BUILD_DATE)" \ | |
| 113 | + | --build-arg DIRTY="$(DIRTY)" \ | |
| 108 | 114 | -t $(REPO):$(TAG)-$$arch .; \ | |
| 109 | 115 | $(ENGINE) push $(REPO):$(TAG)-$$arch; \ | |
| 110 | 116 | done | |
M README.md +79 -51
| @@ -2,57 +2,79 @@ | |||
| 2 | 2 | ||
| 3 | 3 | [](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 | 7 | - 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 | 9 | - Discuss: [GitHub Discussions](https://github.com/bnema/gordon/discussions) | |
| 10 | 10 | ||
| 11 | 11 | --- | |
| 12 | 12 | ||
| 13 | 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 | 19 | ## Quick Start | |
| 18 | 20 | ||
| 19 | 21 | ```bash | |
| 20 | - | # Install | |
| 21 | - | curl -fsSL https://gordon.bnema.dev/install.sh | sh | |
| 22 | + | # Install the latest stable release to ~/.local/bin | |
| 23 | + | curl -fsSL https://bnema.dev/gordon/install | sh | |
| 22 | 24 | ||
| 23 | - | # Start the server | |
| 25 | + | # Start the server (restart your shell first if the installer updated PATH) | |
| 24 | 26 | 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 | 35 | ## 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 | 39 | ```bash | |
| 34 | - | # Push using an explicit domain override | |
| 35 | - | gordon push myapp:latest --domain app.example.com | |
| 40 | + | # Push the image (OCI transfer only, never deploys) | |
| 41 | + | gordon images push myapp --build --remote prod | |
| 42 | + | ||
| 43 | + | # Declare the app (blog.toml references the pushed tag) | |
| 44 | + | gordon apps apply --file blog.toml --remote prod | |
| 36 | 45 | ||
| 37 | - | # Or add a route manually, then deploy | |
| 38 | - | gordon routes add app.example.com myapp:latest | |
| 39 | - | gordon routes deploy app.example.com | |
| 46 | + | # Activate it | |
| 47 | + | gordon apps deploy blog --remote prod | |
| 40 | 48 | ||
| 41 | 49 | # Check status | |
| 42 | - | gordon status | |
| 50 | + | 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 | 69 | ```bash | |
| 48 | - | gordon pin app.example.com # Pin to a specific image tag | |
| 49 | - | gordon restart app.example.com # Restart the container | |
| 50 | - | gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123 | |
| 70 | + | gordon apps restart blog --remote prod # Restart from pinned digests | |
| 71 | + | gordon apps stop blog --remote prod # Stop, preserve all data | |
| 72 | + | gordon apps secrets set blog --service web DATABASE_URL=... --remote prod | |
| 51 | 73 | ``` | |
| 52 | 74 | ||
| 53 | 75 | ## 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 | 79 | ### GitHub Actions | |
| 58 | 80 | ||
| @@ -64,17 +86,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on | |||
| 64 | 86 | password: ${{ secrets.GORDON_TOKEN }} | |
| 65 | 87 | ``` | |
| 66 | 88 | ||
| 67 | - | ### Docker CLI | |
| 89 | + | See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options. | |
| 90 | + | ||
| 91 | + | ### Docker CLI + Gordon CLI | |
| 68 | 92 | ||
| 69 | 93 | ```bash | |
| 70 | - | docker login registry.mydomain.com | |
| 71 | - | docker build -t registry.mydomain.com/myapp:v1.0.0 . | |
| 72 | - | docker push registry.mydomain.com/myapp:v1.0.0 | |
| 73 | - | # -> Deployed automatically | |
| 94 | + | docker login gordon.mydomain.com | |
| 95 | + | docker build -t gordon.mydomain.com/myapp:v1.0.0 . | |
| 96 | + | docker push gordon.mydomain.com/myapp:v1.0.0 | |
| 97 | + | # Then, with the Gordon binary: | |
| 98 | + | gordon apps apply --file blog.toml --remote prod | |
| 99 | + | gordon apps deploy blog --remote prod | |
| 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 | 102 | ## CLI Commands | |
| 79 | 103 | ||
| 80 | 104 | ### Server | |
| @@ -82,36 +106,41 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 82 | 106 | | Command | Description | | |
| 83 | 107 | |---------|-------------| | |
| 84 | 108 | | `gordon serve` | Start the Gordon server | | |
| 85 | - | | `gordon status` | Show server and route health | | |
| 86 | - | | `gordon config show` | Display server configuration | | |
| 109 | + | | `gordon daemon status` | Show server and app fleet status | | |
| 110 | + | | `gordon daemon logs` | Show Gordon process logs | | |
| 111 | + | | `gordon daemon reload` | Reload installation configuration | | |
| 112 | + | | `gordon daemon config show` | Display installation configuration | | |
| 87 | 113 | ||
| 88 | - | ### Deployment | |
| 114 | + | ### Applications | |
| 89 | 115 | ||
| 90 | 116 | | Command | Description | | |
| 91 | 117 | |---------|-------------| | |
| 92 | - | | `gordon push [image]` | Tag, push, and optionally deploy an image | | |
| 93 | - | | `gordon routes list` | List all routes | | |
| 94 | - | | `gordon routes add <domain> <image>` | Create or update a route | | |
| 95 | - | | `gordon routes remove <domain>` | Remove a route | | |
| 96 | - | | `gordon routes deploy <domain>` | Redeploy a route | | |
| 97 | - | | `gordon pin <domain>` | Pin a route to a specific image tag | | |
| 98 | - | | `gordon restart <domain>` | Restart a route container | | |
| 118 | + | | `gordon apps apply --file FILE` | Validate and persist an app manifest | | |
| 119 | + | | `gordon apps deploy APP` | Activate an app revision | | |
| 120 | + | | `gordon apps list` | List applications | | |
| 121 | + | | `gordon apps show APP` | Show desired and active state | | |
| 122 | + | | `gordon apps status APP` | Show effective vs observed state | | |
| 123 | + | | `gordon apps logs APP` | Show logs for an app service | | |
| 124 | + | | `gordon apps restart APP` | Restart from pinned digests | | |
| 125 | + | | `gordon apps stop APP` | Stop, preserve all data | | |
| 126 | + | | `gordon apps start APP` | Start a stopped app | | |
| 127 | + | | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) | | |
| 99 | 128 | ||
| 100 | 129 | ### Images & Registry | |
| 101 | 130 | ||
| 102 | 131 | | Command | Description | | |
| 103 | 132 | |---------|-------------| | |
| 133 | + | | `gordon images push [image]` | Tag and push an image (never deploys) | | |
| 104 | 134 | | `gordon images list` | List runtime and registry images | | |
| 105 | 135 | | `gordon images prune` | Clean up dangling images and old tags | | |
| 106 | 136 | | `gordon images tags <repo>` | List registry tags for a repository | | |
| 107 | 137 | ||
| 108 | - | ### Secrets & Config | |
| 138 | + | ### Secrets | |
| 109 | 139 | ||
| 110 | 140 | | Command | Description | | |
| 111 | 141 | |---------|-------------| | |
| 112 | - | | `gordon secrets list <domain>` | List secrets for a route | | |
| 113 | - | | `gordon secrets set <domain> KEY=VAL` | Set secrets | | |
| 114 | - | | `gordon secrets remove <domain> <key>` | Remove a secret | | |
| 142 | + | | `gordon apps secrets list APP` | List app secret names | | |
| 143 | + | | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values | | |
| 115 | 144 | ||
| 116 | 145 | ### Remotes & Auth | |
| 117 | 146 | ||
| @@ -125,23 +154,22 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 125 | 154 | ||
| 126 | 155 | ## Features | |
| 127 | 156 | ||
| 128 | - | - Private Docker registry on your VPS | |
| 157 | + | - Private Docker/Podman registry on your VPS | |
| 158 | + | - Declarative apps: one TOML file per app, explicit apply then deploy | |
| 129 | 159 | - Domain-to-container routing through a smart TCP edge reverse proxy | |
| 130 | - | - Automatic deployment on image push | |
| 131 | - | - Auto-routing from image labels | |
| 132 | - | - Remote CLI management | |
| 133 | - | - Zero downtime updates | |
| 134 | - | - Persistent volumes from Dockerfile VOLUME directives | |
| 135 | - | - Environment variable management with secrets support | |
| 136 | - | - Network isolation per application | |
| 137 | - | - Single binary, ~15MB RAM | |
| 160 | + | - Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish) | |
| 161 | + | - Remote CLI management (daemon is the sole writer) | |
| 162 | + | - Declarative per-service volumes, retained across lifecycle operations | |
| 163 | + | - Per-service secrets in pass, app-wide public env in the manifest | |
| 164 | + | - Per-app private networks plus opt-in shared networks | |
| 165 | + | - Single binary | |
| 138 | 166 | ||
| 139 | 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 | 170 | ## 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 | 174 | - [Docs](https://bnema.dev/gordon/docs) — Installation, configuration, CLI reference | |
| 147 | 175 | - [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 | 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 | 140 | Combine scopes with commas: | |
| 141 | 141 | ||
| @@ -271,14 +271,12 @@ Usage: | |||
| 271 | 271 | | `pull` | Pull images from registry | | |
| 272 | 272 | | `push,pull` | Both push and pull (default) | | |
| 273 | 273 | | `admin:*:*` | Full admin access | | |
| 274 | - | | `admin:routes:read` | Read-only routes access | | |
| 275 | - | | `admin:routes:write` | Routes write access | | |
| 274 | + | | `admin:apps:read` | Read-only apps access (list, show, diff, status) | | |
| 275 | + | | `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) | | |
| 276 | 276 | | `admin:config:read` | Read-only config access | | |
| 277 | 277 | | `admin:config:write` | Config write access | | |
| 278 | 278 | | `admin:status:read` | Read-only status/health | | |
| 279 | 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 | 281 | ## 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 | 1 | # 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 databases | |
| 5 | + | Backups are identified by app, service, and the declared resource. Domains are | |
| 6 | + | routing addresses and are never a backup identity. | |
| 7 | + | ||
| 8 | + | ## Declaring backup targets | |
| 9 | + | ||
| 10 | + | A service declares its databases and volumes in the app manifest, and lists | |
| 11 | + | which of them are backed up: | |
| 12 | + | ||
| 13 | + | ```toml | |
| 14 | + | [services.api] | |
| 15 | + | image = "registry.example.com/shop/api:1.4.2" | |
| 16 | + | ||
| 17 | + | [[services.api.database]] | |
| 18 | + | name = "orders" | |
| 19 | + | type = "postgres" | |
| 20 | + | schedule = "daily" | |
| 21 | + | ||
| 22 | + | [[services.api.volume]] | |
| 23 | + | name = "data" | |
| 24 | + | path = "/var/lib/data" | |
| 25 | + | ||
| 26 | + | [services.api.backup] | |
| 27 | + | postgres = ["orders"] | |
| 28 | + | volume = ["data"] | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | A declared database or volume that the service's backup declaration does not | |
| 32 | + | reference is not a backup target. | |
| 33 | + | ||
| 34 | + | Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts | |
| 35 | + | only declared databases and volumes, and Gordon never archives an | |
| 36 | + | operator-owned host path exposed through a bind. | |
| 37 | + | ||
| 38 | + | ## gordon backups | |
| 6 | 39 | ||
| 7 | 40 | Database backups are logical PostgreSQL backups made with `pg_dump`. | |
| 8 | 41 | ||
| 9 | 42 | ```bash | |
| 10 | - | gordon backups databases <subcommand> | |
| 43 | + | gordon backups <subcommand> | |
| 11 | 44 | ``` | |
| 12 | 45 | ||
| 13 | 46 | Subcommands: | |
| 14 | 47 | ||
| 15 | - | - `list [domain]` - List database backups | |
| 16 | - | - `run <domain> [--db <name>]` - Trigger an immediate database backup | |
| 17 | - | - `detect <domain>` - Detect supported databases for a domain | |
| 18 | - | - `status` - Show database backup status | |
| 19 | - | ||
| 20 | - | Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups. | |
| 48 | + | - `list [app]` - List stored backups, for every app or one app | |
| 49 | + | - `run <app> --service <service> --database <database>` - Run one declared | |
| 50 | + | database backup now | |
| 51 | + | - `status` - Show stored backups plus declared targets that have no completed | |
| 52 | + | backup yet | |
| 21 | 53 | ||
| 22 | - | ## gordon backups volumes | |
| 54 | + | ## 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 | 59 | ```bash | |
| 27 | - | gordon backups volumes <subcommand> | |
| 60 | + | gordon backups volume <subcommand> | |
| 28 | 61 | ``` | |
| 29 | 62 | ||
| 30 | 63 | Subcommands: | |
| 31 | 64 | ||
| 32 | - | - `list [domain]` - List completed volume backup archives | |
| 33 | - | - `run [domain] [--volume <name>]` - Trigger volume backups now | |
| 65 | + | - `list [app]` - List completed volume backup archives | |
| 66 | + | - `run <app> --service <service> --volume <volume>` - Run one declared volume | |
| 67 | + | backup now | |
| 34 | 68 | - `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 | 84 | ## Examples | |
| 39 | 85 | ||
| 40 | 86 | ```bash | |
| 41 | 87 | # Database backups | |
| 42 | - | gordon backups databases list | |
| 43 | - | gordon backups databases run app.example.com --db postgres | |
| 44 | - | gordon backups databases detect app.example.com | |
| 45 | - | gordon backups databases status | |
| 88 | + | gordon backups list | |
| 89 | + | gordon backups list shop | |
| 90 | + | gordon backups run shop --service api --database orders | |
| 91 | + | gordon backups status | |
| 46 | 92 | ||
| 47 | 93 | # Volume backups | |
| 48 | - | gordon backups volumes list | |
| 49 | - | gordon backups volumes list app.example.com | |
| 50 | - | gordon backups volumes run | |
| 51 | - | gordon backups volumes run app.example.com --volume gordon-app-example-com-data | |
| 52 | - | gordon backups volumes status | |
| 94 | + | gordon backups volume list | |
| 95 | + | gordon backups volume list shop | |
| 96 | + | gordon backups volume run shop --service api --volume data | |
| 97 | + | gordon backups volume status | |
| 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 | 107 | ## JSON Output | |
| 56 | 108 | ||
| 57 | - | Database and volume list commands support `--json`. | |
| 109 | + | Every list, run, and status command supports `--json`. | |
| 58 | 110 | ||
| 59 | 111 | ```bash | |
| 60 | - | gordon backups volumes list --json | |
| 112 | + | gordon backups volume list --json | |
| 61 | 113 | ``` | |
| 62 | 114 | ||
| 63 | 115 | ## Required Permissions | |
| 64 | 116 | ||
| 65 | - | - Read operations (`list`, `status`, `detect`) require `admin:status:read`. | |
| 117 | + | - Read operations (`list`, `status`) require `admin:status:read`. | |
| 66 | 118 | - `run` requires `admin:config:write`. | |
| 67 | 119 | ||
| 68 | 120 | ## Related | |
| 69 | 121 | ||
| 70 | 122 | - [CLI Commands](./index.md) | |
| 71 | 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 | 1 | # Images Command | |
| 2 | 2 | ||
| 3 | - | List and prune runtime/registry images. | |
| 3 | + | Push, list, and prune runtime/registry images. | |
| 4 | 4 | ||
| 5 | 5 | ## gordon images | |
| 6 | 6 | ||
| @@ -10,12 +10,126 @@ gordon images <subcommand> | |||
| 10 | 10 | ||
| 11 | 11 | Subcommands: | |
| 12 | 12 | ||
| 13 | + | - `push` - Tag and push an image to the Gordon registry (never deploys). | |
| 13 | 14 | - `list` - List runtime images and registry tags. | |
| 14 | 15 | - `prune` - Prune dangling runtime images and old registry tags. | |
| 15 | 16 | - `tags` - List registry tags for a specific repository. | |
| 16 | 17 | ||
| 17 | 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 | 133 | ## gordon images list | |
| 20 | 134 | ||
| 21 | 135 | ```bash | |
| @@ -59,11 +173,13 @@ gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] | |||
| 59 | 173 | ||
| 60 | 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 | 178 | Flags: | |
| 63 | 179 | ||
| 64 | 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 | 183 | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | | |
| 68 | 184 | | `--dangling` | `false` | Restrict scope to dangling runtime images only | | |
| 69 | 185 | | `--registry` | `false` | Restrict scope to registry tag retention only | | |
| @@ -80,7 +196,22 @@ Flags: | |||
| 80 | 196 | ||
| 81 | 197 | - `latest` is always preserved when present. | |
| 82 | 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 | 216 | ## gordon images tags | |
| 86 | 217 | ||
| @@ -128,7 +259,8 @@ gordon images prune --dangling | |||
| 128 | 259 | # Prune registry tags only, keeping latest + 5 previous | |
| 129 | 260 | gordon images prune --registry --keep-releases 5 | |
| 130 | 261 | ||
| 131 | - | # Preview cleanup without applying | |
| 262 | + | # Inspect what prune would remove without applying | |
| 263 | + | # (also lists every skipped candidate with its reason) | |
| 132 | 264 | gordon images prune --dry-run | |
| 133 | 265 | ||
| 134 | 266 | # Skip confirmation prompt | |
M docs/cli/index.md +56 -124
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | # 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 | 7 | Commands are organized by where they run: | |
| 8 | 8 | ||
| @@ -20,30 +20,15 @@ Commands are organized by where they run: | |||
| 20 | 20 | ||
| 21 | 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 | 25 | | Command | Description | Documentation | | |
| 26 | 26 | |---------|-------------|---------------| | |
| 27 | - | | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) | | |
| 28 | - | | `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) | | |
| 29 | - | | `gordon backups` | Manage database backups | [backup](./backup.md) | | |
| 30 | - | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | |
| 31 | - | | `gordon config show` | Show server configuration | [config](./config.md) | | |
| 32 | - | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | |
| 33 | - | | `gordon images` | List and prune images | [images](./images.md) | | |
| 34 | - | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 35 | - | | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) | | |
| 36 | - | | `gordon preview` | Create or manage preview environments | [preview](../config/preview.md) | | |
| 37 | - | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | |
| 38 | - | | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) | | |
| 39 | - | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 40 | - | | `gordon restart` | Restart a running container | [restart](./restart.md) | | |
| 41 | - | | `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) | | |
| 42 | - | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| 43 | - | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 44 | - | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 45 | - | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |
| 46 | - | | `gordon volumes` | Manage volumes | - | | |
| 27 | + | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | |
| 28 | + | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | |
| 29 | + | | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) | | |
| 30 | + | | `gordon images` | Push, list, and prune images | [images](./images.md) | | |
| 31 | + | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | |
| 47 | 32 | ||
| 48 | 33 | ## Client Commands | |
| 49 | 34 | ||
| @@ -60,54 +45,59 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 60 | 45 | gordon serve | |
| 61 | 46 | gordon serve --config /path/to/config.toml | |
| 62 | 47 | ||
| 63 | - | # Reload configuration | |
| 64 | - | gordon reload | |
| 65 | - | ||
| 66 | - | # Deploy a specific route | |
| 67 | - | gordon deploy myapp.example.com | |
| 68 | - | ||
| 69 | - | # Restart a running container | |
| 70 | - | gordon restart myapp.example.com | |
| 71 | - | ||
| 72 | - | # First-time route setup | |
| 73 | - | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 74 | - | ||
| 75 | - | # Then push and deploy | |
| 76 | - | gordon push myapp:latest --domain app.example.com --build --no-confirm | |
| 77 | - | ||
| 78 | - | # Push an image and deploy | |
| 79 | - | gordon push myapp --build | |
| 80 | - | ||
| 81 | - | # Push and deploy without confirmation | |
| 82 | - | gordon push myapp --no-confirm | |
| 83 | - | ||
| 84 | - | # Pin to a specific tag | |
| 85 | - | gordon pin myapp.example.com | |
| 48 | + | # Reload installation configuration (never activates app state) | |
| 49 | + | gordon daemon reload | |
| 50 | + | ||
| 51 | + | # Applications (daemon-owned; fail without a reachable daemon) | |
| 52 | + | gordon apps apply --file blog.toml | |
| 53 | + | gordon apps apply --file blog.toml --deploy | |
| 54 | + | gordon apps list | |
| 55 | + | gordon apps show blog | |
| 56 | + | gordon apps diff blog | |
| 57 | + | gordon apps deploy blog | |
| 58 | + | gordon apps restart blog | |
| 59 | + | gordon apps stop blog | |
| 60 | + | gordon apps start blog | |
| 61 | + | gordon apps remove blog | |
| 62 | + | gordon apps operations show blog --key <operation-key> | |
| 63 | + | gordon apps operations watch blog --key <operation-key> | |
| 64 | + | gordon apps secrets list blog | |
| 65 | + | ||
| 66 | + | # Daemon | |
| 67 | + | gordon daemon status | |
| 68 | + | gordon daemon config show | |
| 69 | + | gordon daemon config validate --file /path/to/gordon.toml | |
| 70 | + | gordon daemon tls | |
| 71 | + | gordon daemon networks | |
| 72 | + | ||
| 73 | + | # Push an image (OCI transfer only; deploy separately) | |
| 74 | + | gordon images push myapp --build --remote prod | |
| 86 | 75 | ||
| 87 | 76 | # View logs | |
| 88 | - | gordon logs # Gordon process logs | |
| 89 | - | gordon logs -f # Follow process logs | |
| 90 | - | gordon logs -n 100 # Last 100 lines | |
| 91 | - | gordon logs myapp.example.com # Container logs for myapp.example.com | |
| 92 | - | gordon logs myapp.example.com -f # Follow container logs | |
| 77 | + | gordon daemon logs # Gordon process logs | |
| 78 | + | gordon daemon logs -f # Follow process logs | |
| 79 | + | gordon daemon logs -n 100 # Last 100 process-log lines | |
| 80 | + | gordon apps logs blog --service web # App service logs | |
| 81 | + | gordon apps logs blog --service web --follow # Follow app service logs | |
| 93 | 82 | ||
| 94 | 83 | # Check version | |
| 95 | 84 | gordon version | |
| 96 | 85 | ||
| 97 | 86 | # Traffic plane | |
| 98 | - | gordon traffic status --remote prod | |
| 99 | - | gordon traffic status --remote prod --json | |
| 87 | + | gordon daemon traffic --remote prod | |
| 88 | + | gordon daemon traffic --remote prod --json | |
| 100 | 89 | ||
| 101 | 90 | # Backups | |
| 102 | 91 | gordon backups list | |
| 103 | - | gordon backups run app.example.com | |
| 104 | - | gordon backups detect app.example.com | |
| 92 | + | gordon backups run shop --service api --database orders | |
| 93 | + | gordon backups volume run shop --service api --volume data | |
| 105 | 94 | gordon backups status | |
| 106 | 95 | ||
| 107 | 96 | # Images | |
| 97 | + | gordon images push myapp --build --remote prod | |
| 108 | 98 | gordon images list | |
| 109 | - | gordon images prune --runtime-only | |
| 110 | - | gordon images prune --keep 3 | |
| 99 | + | gordon images prune --dry-run | |
| 100 | + | gordon images prune --keep-releases 3 | |
| 111 | 101 | ||
| 112 | 102 | # Authentication | |
| 113 | 103 | gordon auth login --remote https://gordon.example.com --token $TOKEN | |
| @@ -119,43 +109,14 @@ gordon auth token list | |||
| 119 | 109 | gordon auth token revoke <token-id> | |
| 120 | 110 | 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 | 112 | # Remotes | |
| 140 | 113 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |
| 141 | 114 | gordon remotes list | |
| 142 | 115 | 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 | 117 | # Volumes | |
| 151 | 118 | gordon volumes list | |
| 152 | 119 | 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 | 122 | ## Global Options | |
| @@ -173,64 +134,35 @@ The CLI can target remote Gordon instances using client config, an active remote | |||
| 173 | 134 | or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides | |
| 174 | 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 now | |
| 177 | - | **auto-infer a saved remote** for commands that already have a concrete target. It probes your | |
| 178 | - | saved remotes and uses the remote automatically when exactly one matches. If multiple remotes | |
| 179 | - | match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe | |
| 180 | - | fails, Gordon also stops rather than guessing. | |
| 181 | - | ||
| 182 | - | Auto-inference currently applies to target-based commands such as: | |
| 183 | - | - `gordon push` | |
| 184 | - | - `gordon attachments push` | |
| 185 | - | - `gordon deploy <domain>` | |
| 186 | - | - `gordon restart <domain>` | |
| 187 | - | - `gordon pin <domain>` / `gordon pin list <domain>` | |
| 188 | - | - `gordon routes show <domain>` / `gordon routes remove <domain>` | |
| 189 | - | - `gordon secrets list|set|remove <domain>` | |
| 190 | - | - `gordon attachments list <target>` / `gordon attachments add|remove <target> ...` | |
| 191 | - | - `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>` | |
| 192 | - | - `gordon images tags <repository>` | |
| 193 | - | - `gordon logs <domain>` | |
| 194 | - | ||
| 195 | - | `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote` | |
| 196 | - | nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either | |
| 197 | - | one to force a single target. | |
| 137 | + | When no explicit remote is selected and no active remote is configured, Gordon can | |
| 138 | + | **auto-infer a saved remote** for `gordon images push`. It probes your saved remotes and uses the | |
| 139 | + | remote automatically when exactly one matches. If multiple remotes match, Gordon stops with | |
| 140 | + | an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also | |
| 141 | + | stops rather than guessing. | |
| 198 | 142 | ||
| 199 | 143 | ```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 | 144 | # Auto-inferred single match from saved remotes | |
| 209 | - | gordon push myapp --build | |
| 210 | - | gordon deploy app.example.com | |
| 145 | + | gordon images push myapp --build | |
| 211 | 146 | ``` | |
| 212 | 147 | ||
| 213 | 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 | 150 | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |
| 217 | 151 | You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml` | |
| 218 | 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 | 154 | ```bash | |
| 223 | 155 | # Using flags (use the gordon_domain from remote Gordon config) | |
| 224 | - | gordon routes list --remote https://gordon.example.com --token $TOKEN | |
| 156 | + | gordon daemon status --remote https://gordon.example.com --token $TOKEN | |
| 225 | 157 | ||
| 226 | 158 | # Against self-signed/private CA endpoint | |
| 227 | - | gordon --remote https://gordon.example.com --token $TOKEN --insecure status | |
| 159 | + | gordon --remote https://gordon.example.com --token $TOKEN --insecure daemon status | |
| 228 | 160 | ||
| 229 | 161 | # Using environment variables | |
| 230 | 162 | export GORDON_REMOTE=https://gordon.example.com | |
| 231 | 163 | export GORDON_TOKEN=$TOKEN | |
| 232 | 164 | export GORDON_INSECURE=true | |
| 233 | - | gordon routes list | |
| 165 | + | gordon daemon status | |
| 234 | 166 | ``` | |
| 235 | 167 | ||
| 236 | 168 | ## 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 | 163 | ### Description | |
| 164 | 164 | ||
| 165 | 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 | 169 | ```bash | |
| 171 | 170 | gordon remotes use prod | |
| 172 | - | gordon secrets list app.com # Uses prod remote automatically | |
| 173 | - | gordon routes list # Aggregates local + saved remotes | |
| 174 | - | gordon routes status # Aggregates local + saved remotes | |
| 171 | + | gordon apps list # Uses prod remote automatically | |
| 172 | + | gordon daemon status # Uses prod remote automatically | |
| 175 | 173 | ``` | |
| 176 | 174 | ||
| 177 | 175 | ### Example | |
| @@ -246,7 +244,7 @@ token = "eyJ..." | |||
| 246 | 244 | ||
| 247 | 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 | 248 | Auto-inference is not used for those aggregate views. | |
| 251 | 249 | ||
| 252 | 250 | **Remote target selection:** | |
| @@ -271,13 +269,8 @@ Auto-inference is not used for those aggregate views. | |||
| 271 | 269 | This allows overriding specific values while keeping defaults: | |
| 272 | 270 | ||
| 273 | 271 | ```bash | |
| 274 | - | # Aggregate routes views | |
| 275 | - | gordon routes list | |
| 276 | - | gordon routes status | |
| 277 | - | ||
| 278 | 272 | # Force one target | |
| 279 | - | GORDON_REMOTE=prod gordon routes list | |
| 280 | - | GORDON_REMOTE=prod gordon routes status | |
| 273 | + | GORDON_REMOTE=prod gordon daemon 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 | 288 | # Work with prod | |
| 296 | 289 | gordon remotes use prod | |
| 297 | - | gordon secrets list myapp.example.com | |
| 298 | - | gordon routes list --remote prod | |
| 290 | + | gordon apps list | |
| 291 | + | gordon daemon status | |
| 299 | 292 | ||
| 300 | 293 | # Switch to staging | |
| 301 | 294 | gordon remotes use staging | |
| 302 | - | GORDON_REMOTE=staging gordon routes status | |
| 295 | + | GORDON_REMOTE=staging gordon daemon status | |
| 303 | 296 | ``` | |
| 304 | 297 | ||
| 305 | 298 | ### CI/CD Pipeline | |
| @@ -311,25 +304,21 @@ env: | |||
| 311 | 304 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 312 | 305 | ||
| 313 | 306 | steps: | |
| 314 | - | - name: Deploy to Gordon | |
| 307 | + | - name: Push to Gordon | |
| 315 | 308 | run: | | |
| 316 | - | gordon routes deploy myapp.example.com | |
| 309 | + | gordon images push myapp --build --remote ${{ secrets.GORDON_URL }} | |
| 317 | 310 | ``` | |
| 318 | 311 | ||
| 319 | 312 | ### Compare Environments | |
| 320 | 313 | ||
| 321 | 314 | ```bash | |
| 322 | - | # Aggregate route inventory/status across local + saved remotes | |
| 323 | - | gordon routes list | |
| 324 | - | gordon routes status | |
| 325 | - | ||
| 326 | 315 | # Compare one target at a time | |
| 327 | - | gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN | |
| 328 | - | GORDON_REMOTE=staging gordon routes status | |
| 316 | + | gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN | |
| 317 | + | GORDON_REMOTE=staging gordon daemon status | |
| 329 | 318 | ||
| 330 | 319 | # Or switch between active remotes for other commands | |
| 331 | - | gordon remotes use prod && gordon secrets list myapp.example.com | |
| 332 | - | gordon remotes use staging && gordon secrets list myapp.example.com | |
| 320 | + | gordon remotes use prod && gordon apps list | |
| 321 | + | gordon remotes use staging && gordon apps list | |
| 333 | 322 | ``` | |
| 334 | 323 | ||
| 335 | 324 | ### 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 | 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 | 348 | ## 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 | 23 | Starts the Gordon server, which includes: | |
| 24 | 24 | ||
| 25 | 25 | - **Container Registry** - Receives image pushes on the registry port | |
| 26 | - | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |
| 27 | - | - **Event Bus** - Coordinates deployments and updates | |
| 26 | + | - **Reverse Proxy** - Routes app hosts to recorded loopback backends | |
| 27 | + | - **Event Bus** - Coordinates runtime notifications | |
| 28 | 28 | - **Config Watcher** - Monitors configuration file for changes | |
| 29 | 29 | ||
| 30 | 30 | ### Configuration File Detection | |
| @@ -68,8 +68,8 @@ Gordon responds to these signals: | |||
| 68 | 68 | |--------|--------| | |
| 69 | 69 | | `SIGTERM` | Graceful shutdown | | |
| 70 | 70 | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |
| 71 | - | | `SIGUSR1` | Reload configuration | | |
| 72 | - | | `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) | | |
| 71 | + | | `SIGUSR1` | Reload installation configuration | | |
| 72 | + | | `SIGUSR2` | Reserved; no app deployment action | | |
| 73 | 73 | ||
| 74 | 74 | ### Running with systemd | |
| 75 | 75 | ||
| @@ -106,21 +106,16 @@ sudo loginctl enable-linger $USER | |||
| 106 | 106 | 7. Register event handlers | |
| 107 | 107 | 8. Start config file watcher | |
| 108 | 108 | 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 | 111 | ### Startup Recovery | |
| 112 | 112 | ||
| 113 | 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. | |
| 116 | - | 2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container. | |
| 117 | - | 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass. | |
| 115 | + | 1. **Reconcile app boot state** - Gordon ensures apps intended to run are running from active state (stopped-intent apps stay stopped, pending desired revisions are never activated). | |
| 116 | + | 4. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass. | |
| 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. | |
| 120 | - | ||
| 121 | - | Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes. | |
| 122 | - | ||
| 123 | - | Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running. | |
| 118 | + | Reload and startup recovery never activate pending desired app state, re-resolve images, or flip intent. They touch installation settings and runtime reconciliation only. | |
| 124 | 119 | ||
| 125 | 120 | ### 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 | 131 | ## gordon version | |
| 295 | 132 | ||
| 296 | 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 | 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 | 24 | ```bash | |
| 25 | 25 | gordon volumes prune | |
| @@ -29,10 +29,22 @@ gordon volumes prune --no-confirm | |||
| 29 | 29 | ||
| 30 | 30 | | Flag | Description | | |
| 31 | 31 | |------|-------------| | |
| 32 | - | | `--dry-run` | Show what would be removed without deleting | | |
| 32 | + | | `--dry-run` | Report the plan without deleting | | |
| 33 | 33 | | `--no-confirm` | Skip confirmation prompt | | |
| 34 | 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 | 48 | ## Related | |
| 37 | 49 | ||
| 38 | 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 | 9 | Gordon flips the typical deployment model: | |
| 10 | 10 | ||
| 11 | 11 | 1. **Build locally** where you have computing power | |
| 12 | - | 2. **Push the finished image** to your VPS | |
| 13 | - | 3. **Gordon deploys automatically** | |
| 12 | + | 2. **Push the finished image** to your VPS registry | |
| 13 | + | 3. **Apply the app manifest** declaring the image | |
| 14 | + | 4. **Deploy** to activate it | |
| 14 | 15 | ||
| 15 | 16 | This means faster builds, less VPS resource usage, and a simpler deployment workflow. | |
| 16 | 17 | ||
| 17 | - | ## Push-to-Deploy | |
| 18 | + | ## 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 | 23 | ┌──────────────┐ push ┌──────────────┐ | |
| 23 | 24 | │ docker build │ ──────────────>│ Gordon │ | |
| 24 | - | │ docker push │ │ Registry │ | |
| 25 | - | └──────────────┘ └──────┬───────┘ | |
| 25 | + | │ docker push │ (OCI only, │ Registry │ | |
| 26 | + | └──────────────┘ never deploys)└──────┬───────┘ | |
| 26 | 27 | │ | |
| 27 | - | │ event: image.pushed | |
| 28 | + | ┌───────────────────┴───────────────────┐ | |
| 29 | + | │ gordon apps apply --file blog.toml │ desired state | |
| 30 | + | │ gordon apps deploy blog │ activation | |
| 31 | + | └───────────────────┬───────────────────┘ | |
| 28 | 32 | v | |
| 29 | 33 | ┌──────────────┐ | |
| 30 | - | │ Deploy │ | |
| 31 | - | │ Container │ | |
| 34 | + | │ App │ | |
| 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 registry | |
| 38 | - | 2. Fires an `image.pushed` event | |
| 39 | - | 3. Looks up the route for that image | |
| 40 | - | 4. Deploys a new container | |
| 41 | - | 5. Updates the proxy routing | |
| 42 | - | 6. Stops the old container | |
| 41 | + | ## Declarative Apps | |
| 43 | 42 | ||
| 44 | - | ## Zero-Downtime Updates | |
| 43 | + | One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities. | |
| 45 | 44 | ||
| 46 | - | Gordon ensures your app stays available during updates: | |
| 47 | - | ||
| 48 | - | 1. **New container starts** while old container is still running | |
| 49 | - | 2. **Health check** waits for new container to be ready | |
| 50 | - | 3. **Traffic switches** to the new container | |
| 51 | - | 4. **Old container stops** after traffic has moved | |
| 52 | - | ||
| 53 | - | ``` | |
| 54 | - | Time ─────────────────────────────────────────────> | |
| 55 | - | ||
| 56 | - | Old Container: [═══════════════════] | |
| 57 | - | ↓ stop | |
| 58 | - | New Container: [═════════════════════════> | |
| 59 | - | ↑ start ↑ traffic routed | |
| 60 | - | ``` | |
| 61 | - | ||
| 62 | - | ## Deletion and Cleanup Lifecycle | |
| 45 | + | ```toml | |
| 46 | + | name = "blog" | |
| 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`. | |
| 67 | - | - **active** — an entity has runtime state, such as a running Gordon-managed container. | |
| 68 | - | - **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data. | |
| 69 | - | - **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis. | |
| 70 | - | - **purged** — state explicitly deleted by a destructive cleanup command. | |
| 71 | - | - **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later. | |
| 51 | + | [services.web] | |
| 52 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 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`. | |
| 80 | - | - Gordon can stop and remove containers. | |
| 81 | - | - Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category. | |
| 82 | - | - Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added. | |
| 69 | + | ## Updates | |
| 83 | 70 | ||
| 84 | - | ## Routes | |
| 71 | + | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time. | |
| 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 | - | ```toml | |
| 89 | - | [routes] | |
| 90 | - | "app.example.com" = { image = "myapp:latest" } | |
| 91 | - | "api.example.com" = { image = "myapi:v2.1.0" } | |
| 75 | + | ``` | |
| 76 | + | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | |
| 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. | |
| 95 | - | ||
| 96 | - | When a request comes in for `app.example.com`, Gordon: | |
| 79 | + | If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. | |
| 97 | 80 | ||
| 98 | - | 1. Looks up the route configuration | |
| 99 | - | 2. Finds the running container for `myapp:latest` | |
| 100 | - | 3. Proxies the request to that container | |
| 81 | + | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | |
| 101 | 82 | ||
| 102 | - | ### Route Domains | |
| 83 | + | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. | |
| 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 | - | ```toml | |
| 107 | - | [routes] | |
| 108 | - | "dev-app.example.com" = { image = "internal-app:latest", https = false } | |
| 109 | - | ``` | |
| 87 | + | `gordon apps restart` restarts the same container and applies nothing: it withdraws traffic, restarts the pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned `ACTIVE` digest and publishes it. | |
| 110 | 88 | ||
| 111 | - | ## Network Isolation | |
| 112 | - | ||
| 113 | - | Each app runs in its own isolated Docker network: | |
| 89 | + | ## Deletion and Cleanup Lifecycle | |
| 114 | 90 | ||
| 115 | - | ``` | |
| 116 | - | ┌────────────────────────────────────────────────┐ | |
| 117 | - | │ gordon-app-mydomain-com │ | |
| 118 | - | │ │ | |
| 119 | - | │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ | |
| 120 | - | │ │ App │───>│ Postgres │ │ Redis │ │ | |
| 121 | - | │ │ :3000 │ │ :5432 │ │ :6379 │ │ | |
| 122 | - | │ └──────────┘ └──────────┘ └──────────┘ │ | |
| 123 | - | │ │ | |
| 124 | - | └────────────────────────────────────────────────┘ | |
| 125 | - | ``` | |
| 91 | + | Gordon separates workload removal from destructive data cleanup: | |
| 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 services | |
| 130 | - | - Services are only accessible by name within their network | |
| 131 | - | - No port conflicts between apps | |
| 98 | + | Safe removal is the default. `gordon apps remove` withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no `purge`: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes. | |
| 132 | 99 | ||
| 133 | - | ## Attachments | |
| 100 | + | ## 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 | 104 | ```toml | |
| 138 | - | [attachments] | |
| 139 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 105 | + | [[services.web.http]] | |
| 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 | - | ```javascript | |
| 145 | - | // In your app | |
| 146 | - | const db = await connect("postgresql://postgres:5432/mydb"); | |
| 147 | - | const cache = await connect("redis://redis:6379"); | |
| 148 | - | ``` | |
| 112 | + | 1. Looks up the host in the ACTIVE projection (merged with installation external routes) | |
| 113 | + | 2. Finds the recorded loopback backend for that host (never a container IP — rootless-first) | |
| 114 | + | 3. Proxies the request to that backend | |
| 149 | 115 | ||
| 150 | - | ## Network Groups | |
| 116 | + | 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 | - | ```toml | |
| 155 | - | [network_groups] | |
| 156 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 120 | + | Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership: | |
| 157 | 121 | ||
| 158 | - | [attachments] | |
| 159 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 122 | + | ```toml | |
| 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 | 130 | ## 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 | - | ```dockerfile | |
| 169 | - | FROM postgres:18 | |
| 170 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 134 | + | ```toml | |
| 135 | + | [[services.web.volume]] | |
| 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) | |
| 176 | - | - **prefix**: Volume names are prefixed with `gordon-` (configurable) | |
| 177 | - | - **preserve**: Volumes persist across container updates (default: true) | |
| 142 | + | Use `gordon volumes prune --dry-run` to inspect Gordon's ownership-aware plan. Do not use `docker volume prune` or an equivalent runtime command for Gordon data: it bypasses Gordon's retention checks and can delete unmounted retained volumes. | |
| 178 | 143 | ||
| 179 | - | ## Environment Variables | |
| 144 | + | ## 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 Providers | |
| 153 | + | Service-specific values use `secrets` even when non-confidential: | |
| 198 | 154 | ||
| 199 | - | Environment files support secret provider syntax: | |
| 200 | - | ||
| 201 | - | ```bash | |
| 202 | - | # From Unix password manager (pass) | |
| 203 | - | DATABASE_PASSWORD=${pass:myapp/db-password} | |
| 204 | - | ||
| 205 | - | # From SOPS encrypted files | |
| 206 | - | API_SECRET=${sops:secrets.yaml:api.secret} | |
| 155 | + | ```toml | |
| 156 | + | [services.web.secrets] | |
| 157 | + | DATABASE_URL = "database-url" | |
| 207 | 158 | ``` | |
| 208 | 159 | ||
| 209 | - | ## Configuration Hot-Reload | |
| 210 | - | ||
| 211 | - | Gordon watches its config file and reloads automatically: | |
| 160 | + | Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by the stable internal UUID so a removed app's secrets are never adopted by a new app reusing the name. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. Write values with `gordon apps secrets set`; only key names are ever echoed back, never values. | |
| 212 | 161 | ||
| 213 | - | 1. Edit `~/.config/gordon/gordon.toml` | |
| 214 | - | 2. Save the file | |
| 215 | - | 3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes | |
| 216 | - | 4. Containers and proxy config sync to match the new configuration | |
| 162 | + | ## Installation Reload | |
| 217 | 163 | ||
| 218 | - | Route file edits now reload automatically again. | |
| 219 | - | ||
| 220 | - | You can also trigger a reload: | |
| 164 | + | Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads. | |
| 221 | 165 | ||
| 222 | 166 | ```bash | |
| 223 | - | gordon reload | |
| 167 | + | 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 | 172 | ## Event System | |
| 229 | 173 | ||
| 230 | - | Gordon uses an internal event system for coordination: | |
| 231 | - | ||
| 232 | - | | Event | Trigger | Action | | |
| 233 | - | |-------|---------|--------| | |
| 234 | - | | `image.pushed` | Image pushed to registry | Deploy container | | |
| 235 | - | | `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state | | |
| 236 | - | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |
| 237 | - | | `container.deployed` | Container started | Update proxy cache | | |
| 174 | + | Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests. | |
| 238 | 175 | ||
| 239 | 176 | ## 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 | 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 Labels | |
| 184 | + | ## 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 | 188 | | Label | Purpose | | |
| 259 | 189 | |-------|---------| | |
| 260 | - | | `gordon.managed=true` | Identifies Gordon-managed containers | | |
| 261 | - | | `gordon.domain` | Domain this container serves | | |
| 262 | - | | `gordon.image` | Image name and tag | | |
| 263 | - | | `gordon.route` | Route this container handles | | |
| 264 | - | | `gordon.attachment=true` | Container is an attachment service | | |
| 265 | - | | `gordon.attached-to` | Which route this attachment serves | | |
| 266 | - | ||
| 267 | - | ## Proxy Port Selection | |
| 268 | - | ||
| 269 | - | When a container exposes multiple ports, Gordon needs to know which one serves HTTP: | |
| 270 | - | ||
| 271 | - | ```dockerfile | |
| 272 | - | FROM gitea/gitea:latest | |
| 273 | - | LABEL gordon.proxy.port=3000 # Route HTTP to port 3000 | |
| 274 | - | EXPOSE 22 # SSH | |
| 275 | - | EXPOSE 3000 # HTTP | |
| 276 | - | ``` | |
| 190 | + | | `gordon.managed=true` | Identifies Gordon-managed resources | | |
| 191 | + | | `gordon.app` | App public name | | |
| 192 | + | | `gordon.app.service` | Service name | | |
| 193 | + | | `gordon.app.revision` | Active revision that created it | | |
| 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 | 197 | ## Related | |
| 281 | 198 | ||
| 282 | 199 | - [Configuration Reference](./config/index.md) | |
| 283 | - | - [Docker Labels Reference](./reference/docker-labels.md) | |
| 284 | - | - [Environment Variables](./config/env.md) | |
| 200 | + | - [Apps CLI](./cli/apps.md) | |
| 201 | + | - [Getting Started](./getting-started.md) | |
A docs/config/apps.md +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | # App Manifest | |
| 2 | + | ||
| 3 | + | Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`. | |
| 4 | + | ||
| 5 | + | The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential; values stay in pass. | |
| 6 | + | ||
| 7 | + | ## Minimal Example | |
| 8 | + | ||
| 9 | + | ```toml | |
| 10 | + | name = "blog" | |
| 11 | + | ||
| 12 | + | [services.web] | |
| 13 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 14 | + | ||
| 15 | + | [[services.web.http]] | |
| 16 | + | host = "blog.mydomain.com" | |
| 17 | + | port = 3000 | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | Each `[services.<name>]` table declares one service, and the table key is the service name. A name may contain dots, so quote the key when it does: `[services."web.api"]`, `[[services."web.api".http]]`. | |
| 21 | + | ||
| 22 | + | ## Full Example | |
| 23 | + | ||
| 24 | + | ```toml | |
| 25 | + | name = "blog" | |
| 26 | + | ||
| 27 | + | [env] # optional, app-wide public env for all services | |
| 28 | + | APP_ENV = "production" | |
| 29 | + | ||
| 30 | + | [services.web] # one table per service; the key is the name | |
| 31 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 32 | + | command = ["node", "server.js"] # optional override | |
| 33 | + | stop_grace = "30s" # optional, default 30s | |
| 34 | + | devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>] | |
| 35 | + | ||
| 36 | + | [services.web.readiness] # optional explicit readiness | |
| 37 | + | type = "http" | |
| 38 | + | path = "/healthz" | |
| 39 | + | port = 3000 | |
| 40 | + | timeout = "30s" | |
| 41 | + | ||
| 42 | + | [[services.web.http]] # 0..n HTTP interfaces | |
| 43 | + | host = "blog.mydomain.com" | |
| 44 | + | port = 3000 | |
| 45 | + | tls = "auto" # auto | always | never | |
| 46 | + | ||
| 47 | + | [[services.web.http]] # optional private interface | |
| 48 | + | visibility = "internal" # public (default) | internal | |
| 49 | + | port = 8080 # required; no host or tls | |
| 50 | + | ||
| 51 | + | [services.web.secrets] # ENV name -> secret name (values in pass) | |
| 52 | + | DATABASE_URL = "database-url" | |
| 53 | + | ||
| 54 | + | [[services.web.volume]] # 0..n named volumes only | |
| 55 | + | name = "web-data" | |
| 56 | + | path = "/data" | |
| 57 | + | readonly = false | |
| 58 | + | ||
| 59 | + | [[services.web.bind]] # 0..n; name references [app_mounts.<name>] | |
| 60 | + | name = "app-logs" # policy name, never a host path | |
| 61 | + | path = "/var/log/app" # absolute container destination | |
| 62 | + | readonly = true | |
| 63 | + | ||
| 64 | + | [[services.web.database]] # explicit database declarations | |
| 65 | + | name = "main" | |
| 66 | + | type = "postgres" | |
| 67 | + | schedule = "daily" | |
| 68 | + | ||
| 69 | + | [services.web.backup] # backup targets by reference | |
| 70 | + | postgres = ["main"] | |
| 71 | + | volume = ["web-data"] | |
| 72 | + | ||
| 73 | + | [[network.shared]] # optional shared-network memberships | |
| 74 | + | network = "backend" | |
| 75 | + | services = ["web"] | |
| 76 | + | ``` | |
| 77 | + | ||
| 78 | + | ## Identity Rules | |
| 79 | + | ||
| 80 | + | - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness. | |
| 81 | + | - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected. | |
| 82 | + | - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | |
| 83 | + | - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | |
| 84 | + | - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes. | |
| 85 | + | ||
| 86 | + | ## Services | |
| 87 | + | ||
| 88 | + | One or more explicitly named image-backed services per app, each in its own `[services.<name>]` table. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none. | |
| 89 | + | ||
| 90 | + | ```toml | |
| 91 | + | [[services.web.tcp]] | |
| 92 | + | entrypoint = "game" # names installation entrypoints.<name> | |
| 93 | + | port = 25565 | |
| 94 | + | publish = "0.0.0.0:25565" | |
| 95 | + | ||
| 96 | + | [[services.web.udp]] | |
| 97 | + | entrypoint = "game" | |
| 98 | + | port = 28015 | |
| 99 | + | publish = "0.0.0.0:28015" | |
| 100 | + | ``` | |
| 101 | + | ||
| 102 | + | RCON is ordinary TCP: use TCP interfaces, no special RCON kind. | |
| 103 | + | ||
| 104 | + | `publish` must match the entrypoint listener it attaches to exactly: the same host, port, and transport. The traffic manager binds the entrypoint address, so a declaration that differs (for example a loopback bind on a wildcard entrypoint, or a different port) is rejected at apply time instead of being silently widened. `0.0.0.0` and an omitted host are the same wildcard. | |
| 105 | + | ||
| 106 | + | Image registry names and digest syntax are validated during manifest apply, resolution, deployment preflight, and immediately before every pull, including boot, restart, and recovery. Docker Hub (`docker.io` and canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are allowed by default. Add other exact registry hostname+port entries with `images.allowed_registries`; this does not configure credentials, and external resolution and pulls use anonymous access. When `images.require_digest` is enabled, every image reference, including Gordon registry images, must use `@sha256:<64 hex chars>`. See [Images](./images.md). This is a hostname allowlist, not DNS/IP validation or runtime egress enforcement. | |
| 107 | + | ||
| 108 | + | ## Environment and Secrets | |
| 109 | + | ||
| 110 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | |
| 111 | + | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | |
| 112 | + | - There is no `[services.<name>.env]` key — service-specific values must use secrets. | |
| 113 | + | ||
| 114 | + | ## Volumes and Databases | |
| 115 | + | ||
| 116 | + | - Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible. | |
| 117 | + | - Manifests never carry host paths. A `[[services.<name>.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md). | |
| 118 | + | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | |
| 119 | + | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. | |
| 120 | + | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | |
| 121 | + | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. | |
| 122 | + | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | |
| 123 | + | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | |
| 124 | + | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | |
| 125 | + | ||
| 126 | + | ## HTTP Interfaces | |
| 127 | + | ||
| 128 | + | `[[services.<name>.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty. | |
| 129 | + | ||
| 130 | + | ```toml | |
| 131 | + | [[services.web.http]] # public: proxied by host | |
| 132 | + | host = "blog.mydomain.com" | |
| 133 | + | port = 3000 | |
| 134 | + | tls = "auto" # auto | always | never | |
| 135 | + | ||
| 136 | + | [[services.web.http]] # internal: not published (app private network only) | |
| 137 | + | visibility = "internal" | |
| 138 | + | port = 8080 | |
| 139 | + | ``` | |
| 140 | + | ||
| 141 | + | - `public`: `host` is required and must be a valid public hostname, and `tls` is `auto` (default when absent), `always`, or `never`. A public interface gets a proxy route, a global host reservation, a certificate target when TLS applies, and a `127.0.0.1` loopback backend publication. | |
| 142 | + | - `internal`: creates no proxy route, no host reservation, no certificate target, and no host port publication, so it is never reachable through the host or proxy plane. `port` is required, `host` must be absent, and `tls` must be absent — any declared TLS value is rejected. It is still a declared TCP-capable container port for readiness metadata. Reachability follows network membership, not visibility: any container attached to a network this service joins can reach the port — sibling services on the app's own private network, and peers on any `[[network.shared]]` network the service is enrolled in. | |
| 143 | + | ||
| 144 | + | There is no `.internal` pseudo-domain: internal interfaces carry no hostname at all. | |
| 145 | + | ||
| 146 | + | A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected at apply time, because publication is socket-level. Duplicate internal HTTP ports within a service are rejected the same way. | |
| 147 | + | ||
| 148 | + | ## Readiness | |
| 149 | + | ||
| 150 | + | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[services.<name>.readiness]` type; the internal port is never temporarily published to the host to probe it. | |
| 151 | + | ||
| 152 | + | ## TLS | |
| 153 | + | ||
| 154 | + | TLS applies to public interfaces only; an internal interface never declares `tls`. | |
| 155 | + | ||
| 156 | + | - `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled. | |
| 157 | + | - `always` is enforced: a plaintext request to an `always` host is redirected whenever an HTTPS endpoint exists, and refused with `421 Misdirected Request` when none does. The backend is never reached over plaintext. | |
| 158 | + | - `never` stays plaintext and is never issued an app certificate. | |
| 159 | + | ||
| 160 | + | ## Networks | |
| 161 | + | ||
| 162 | + | Each app gets a private network automatically. `[[network.shared]]` adds services to named shared networks, created/reused only within verified Gordon ownership. Deploy adds AND removes memberships without disconnecting unrelated services. | |
| 163 | + | ||
| 164 | + | Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md). | |
| 165 | + | ||
| 166 | + | ## Telemetry | |
| 167 | + | ||
| 168 | + | When [telemetry log export](./telemetry.md#logs) is enabled, the stdout and stderr of every service are exported by default under `service.name = "<app>.<service>"`. `logs = false` opts out; a service setting overrides the app setting: | |
| 169 | + | ||
| 170 | + | ```toml | |
| 171 | + | [telemetry] | |
| 172 | + | logs = false # no service of this app exports... | |
| 173 | + | ||
| 174 | + | [services.web.telemetry] | |
| 175 | + | logs = true # ...except web | |
| 176 | + | ``` | |
| 177 | + | ||
| 178 | + | Changing either setting is a service change: it applies on the next deploy. | |
| 179 | + | ||
| 180 | + | ## Strictness | |
| 181 | + | ||
| 182 | + | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | |
| 183 | + | ||
| 184 | + | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | |
| 185 | + | ||
| 186 | + | ## Related | |
| 187 | + | ||
| 188 | + | - [Apps CLI](../cli/apps.md) | |
| 189 | + | - [Secrets](./secrets.md) | |
| 190 | + | - [Volumes](./volumes.md) | |
| 191 | + | - [External Routes](./external-routes.md) | |
D docs/config/attachments.md +0 -315
| @@ -1,315 +0,0 @@ | |||
| 1 | - | # Attachments Configuration | |
| 2 | - | ||
| 3 | - | Attach service dependencies (databases, caches, queues) to your applications. | |
| 4 | - | ||
| 5 | - | ## Requirements | |
| 6 | - | ||
| 7 | - | **Network isolation must be enabled** for attachments to work properly: | |
| 8 | - | ||
| 9 | - | ```toml | |
| 10 | - | [network_isolation] | |
| 11 | - | enabled = true | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | Without network isolation, containers run on Docker's default bridge network which **does not provide DNS resolution**. Your application won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |
| 15 | - | ||
| 16 | - | ## Configuration | |
| 17 | - | ||
| 18 | - | ```toml | |
| 19 | - | [attachments] | |
| 20 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 21 | - | "api.mydomain.com" = ["postgres:latest"] | |
| 22 | - | ``` | |
| 23 | - | ||
| 24 | - | ## Syntax | |
| 25 | - | ||
| 26 | - | ```toml | |
| 27 | - | [attachments] | |
| 28 | - | "<domain-or-group>" = ["<image>:<tag>", "<image>:<tag>"] | |
| 29 | - | ``` | |
| 30 | - | ||
| 31 | - | | Component | Description | | |
| 32 | - | |-----------|-------------| | |
| 33 | - | | `domain-or-group` | Route domain or network group name | | |
| 34 | - | | `image:tag` | Service images to deploy alongside the app | | |
| 35 | - | ||
| 36 | - | ## How Attachments Work | |
| 37 | - | ||
| 38 | - | When you define attachments: | |
| 39 | - | ||
| 40 | - | 1. Gordon deploys attachment containers to the same network as your app | |
| 41 | - | 2. Services are accessible by their image name (before the colon) | |
| 42 | - | 3. Attachments start before your main application | |
| 43 | - | 4. Attachments persist across app updates | |
| 44 | - | ||
| 45 | - | ``` | |
| 46 | - | ┌───────────────────────────────────────────────────┐ | |
| 47 | - | │ Network: gordon-app-mydomain-com │ | |
| 48 | - | │ │ | |
| 49 | - | │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ | |
| 50 | - | │ │ App │───>│ postgres │ │ redis │ │ | |
| 51 | - | │ │ :3000 │ │ :5432 │ │ :6379 │ │ | |
| 52 | - | │ └──────────┘ └──────────┘ └──────────┘ │ | |
| 53 | - | │ │ | |
| 54 | - | └───────────────────────────────────────────────────┘ | |
| 55 | - | ``` | |
| 56 | - | ||
| 57 | - | ## Service Discovery | |
| 58 | - | ||
| 59 | - | Attachments are accessible by their image name within the network: | |
| 60 | - | ||
| 61 | - | ```toml | |
| 62 | - | [attachments] | |
| 63 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 64 | - | ``` | |
| 65 | - | ||
| 66 | - | In your application: | |
| 67 | - | ```javascript | |
| 68 | - | // Connect using simple hostnames | |
| 69 | - | const db = await pg.connect("postgresql://postgres:5432/mydb"); | |
| 70 | - | const cache = await redis.connect("redis://redis:6379"); | |
| 71 | - | ``` | |
| 72 | - | ||
| 73 | - | ## Persistent Storage | |
| 74 | - | ||
| 75 | - | Use Dockerfile VOLUME directives for persistent data: | |
| 76 | - | ||
| 77 | - | ```dockerfile | |
| 78 | - | # postgres.Dockerfile | |
| 79 | - | FROM postgres:18 | |
| 80 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | - | ENV POSTGRES_DB=myapp | |
| 82 | - | ENV POSTGRES_USER=app | |
| 83 | - | # Password is injected via attachment secrets — do NOT hardcode here | |
| 84 | - | ``` | |
| 85 | - | ||
| 86 | - | Configure credentials via attachment secrets instead of hardcoding them: | |
| 87 | - | ||
| 88 | - | ```bash | |
| 89 | - | # Set database password securely | |
| 90 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 91 | - | ||
| 92 | - | # Verify | |
| 93 | - | gordon secrets list app.mydomain.com | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details. | |
| 97 | - | ||
| 98 | - | Build and push to Gordon: | |
| 99 | - | ```bash | |
| 100 | - | docker build -f postgres.Dockerfile -t my-postgres:latest . | |
| 101 | - | docker push registry.mydomain.com/my-postgres:latest | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Use in attachments: | |
| 105 | - | ```toml | |
| 106 | - | [attachments] | |
| 107 | - | "app.mydomain.com" = ["my-postgres:latest"] | |
| 108 | - | ``` | |
| 109 | - | ||
| 110 | - | ## Attachment Secrets | |
| 111 | - | ||
| 112 | - | Inject environment variables into attachment containers using the `--attachment` flag on secrets commands: | |
| 113 | - | ||
| 114 | - | ```bash | |
| 115 | - | # Set secrets for the postgres attachment | |
| 116 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 117 | - | ||
| 118 | - | # Set secrets for the redis attachment | |
| 119 | - | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret | |
| 120 | - | ||
| 121 | - | # View all secrets (domain + attachments) in a tree view | |
| 122 | - | gordon secrets list app.mydomain.com | |
| 123 | - | ||
| 124 | - | # Remove an attachment secret | |
| 125 | - | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`. | |
| 129 | - | ||
| 130 | - | Storage depends on your secrets backend: | |
| 131 | - | - **pass**: `gordon/env/attachments/<container-name>/<KEY>` | |
| 132 | - | - **sops/unsafe**: `gordon-<container-name>.env` files | |
| 133 | - | ||
| 134 | - | See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details. | |
| 135 | - | ||
| 136 | - | ## Shared Attachments with Network Groups | |
| 137 | - | ||
| 138 | - | Share services between multiple apps using network groups: | |
| 139 | - | ||
| 140 | - | ```toml | |
| 141 | - | [network_groups] | |
| 142 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 143 | - | ||
| 144 | - | [attachments] | |
| 145 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 146 | - | ``` | |
| 147 | - | ||
| 148 | - | Both `app.mydomain.com` and `api.mydomain.com` can access the shared services. | |
| 149 | - | ||
| 150 | - | ## Per-App vs Shared Attachments | |
| 151 | - | ||
| 152 | - | ### Per-App Attachments | |
| 153 | - | ||
| 154 | - | Each app gets its own isolated service instances: | |
| 155 | - | ||
| 156 | - | ```toml | |
| 157 | - | [attachments] | |
| 158 | - | "app.mydomain.com" = ["postgres:latest"] # App's own postgres | |
| 159 | - | "api.mydomain.com" = ["postgres:latest"] # API's own postgres | |
| 160 | - | ``` | |
| 161 | - | ||
| 162 | - | ### Shared Attachments | |
| 163 | - | ||
| 164 | - | Multiple apps share the same service instances: | |
| 165 | - | ||
| 166 | - | ```toml | |
| 167 | - | [network_groups] | |
| 168 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 169 | - | ||
| 170 | - | [attachments] | |
| 171 | - | "backend" = ["postgres:latest", "redis:latest"] # Shared by both | |
| 172 | - | ``` | |
| 173 | - | ||
| 174 | - | ### Mixed Approach | |
| 175 | - | ||
| 176 | - | ```toml | |
| 177 | - | [network_groups] | |
| 178 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 179 | - | ||
| 180 | - | [attachments] | |
| 181 | - | "backend" = ["redis:latest"] # Shared cache | |
| 182 | - | "app.mydomain.com" = ["postgres:latest"] # App's own database | |
| 183 | - | "api.mydomain.com" = ["postgres:latest"] # API's own database | |
| 184 | - | ``` | |
| 185 | - | ||
| 186 | - | ## Examples | |
| 187 | - | ||
| 188 | - | ### Web App with Database | |
| 189 | - | ||
| 190 | - | ```toml | |
| 191 | - | [routes] | |
| 192 | - | "app.mydomain.com" = "myapp:latest" | |
| 193 | - | ||
| 194 | - | [attachments] | |
| 195 | - | "app.mydomain.com" = ["postgres:18", "redis:7-alpine"] | |
| 196 | - | ``` | |
| 197 | - | ||
| 198 | - | ### Microservices with Shared Queue | |
| 199 | - | ||
| 200 | - | ```toml | |
| 201 | - | [routes] | |
| 202 | - | "orders.mydomain.com" = "orders-service:latest" | |
| 203 | - | "inventory.mydomain.com" = "inventory-service:latest" | |
| 204 | - | "notifications.mydomain.com" = "notifications-service:latest" | |
| 205 | - | ||
| 206 | - | [network_groups] | |
| 207 | - | "services" = ["orders.mydomain.com", "inventory.mydomain.com", "notifications.mydomain.com"] | |
| 208 | - | ||
| 209 | - | [attachments] | |
| 210 | - | "services" = ["rabbitmq:3-management"] | |
| 211 | - | "orders.mydomain.com" = ["orders-db:latest"] | |
| 212 | - | "inventory.mydomain.com" = ["inventory-db:latest"] | |
| 213 | - | ``` | |
| 214 | - | ||
| 215 | - | ### Custom Service Images | |
| 216 | - | ||
| 217 | - | Build custom service images with your configuration: | |
| 218 | - | ||
| 219 | - | ```dockerfile | |
| 220 | - | # my-postgres.Dockerfile | |
| 221 | - | FROM postgres:18 | |
| 222 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 223 | - | ENV POSTGRES_DB=production | |
| 224 | - | ENV POSTGRES_USER=appuser | |
| 225 | - | # Password should come from env file | |
| 226 | - | ``` | |
| 227 | - | ||
| 228 | - | ```dockerfile | |
| 229 | - | # my-redis.Dockerfile | |
| 230 | - | FROM redis:7-alpine | |
| 231 | - | VOLUME ["/data"] | |
| 232 | - | CMD ["redis-server", "--appendonly", "yes"] | |
| 233 | - | ``` | |
| 234 | - | ||
| 235 | - | ```toml | |
| 236 | - | [attachments] | |
| 237 | - | "app.mydomain.com" = ["my-postgres:latest", "my-redis:latest"] | |
| 238 | - | ``` | |
| 239 | - | ||
| 240 | - | ## Container Naming | |
| 241 | - | ||
| 242 | - | Attachment containers are named: | |
| 243 | - | - `gordon-<domain>-<image-name>` | |
| 244 | - | - Example: `gordon-app-mydomain-com-postgres` | |
| 245 | - | ||
| 246 | - | ## Labels | |
| 247 | - | ||
| 248 | - | Gordon adds labels to attachment containers: | |
| 249 | - | ||
| 250 | - | | Label | Value | | |
| 251 | - | |-------|-------| | |
| 252 | - | | `gordon.managed` | `true` | | |
| 253 | - | | `gordon.attachment` | `true` | | |
| 254 | - | | `gordon.attached-to` | Domain or group name | | |
| 255 | - | ||
| 256 | - | ## CLI Management | |
| 257 | - | ||
| 258 | - | Manage attachments via the CLI without editing configuration files. | |
| 259 | - | ||
| 260 | - | ### List Attachments | |
| 261 | - | ||
| 262 | - | ```bash | |
| 263 | - | # List all attachments | |
| 264 | - | gordon attachments list | |
| 265 | - | ||
| 266 | - | # List attachments for a specific domain or network group | |
| 267 | - | gordon attachments list app.mydomain.com | |
| 268 | - | gordon attachments list backend | |
| 269 | - | ||
| 270 | - | # Remote mode | |
| 271 | - | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | |
| 272 | - | ``` | |
| 273 | - | ||
| 274 | - | ### Add Attachments | |
| 275 | - | ||
| 276 | - | ```bash | |
| 277 | - | # Add attachment to a domain | |
| 278 | - | gordon attachments add app.mydomain.com postgres:18 | |
| 279 | - | ||
| 280 | - | # Add attachment to a network group | |
| 281 | - | gordon attachments add backend redis:7-alpine | |
| 282 | - | ||
| 283 | - | # Remote mode | |
| 284 | - | gordon attachments add app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 285 | - | ``` | |
| 286 | - | ||
| 287 | - | ### Remove Attachments | |
| 288 | - | ||
| 289 | - | ```bash | |
| 290 | - | # Remove attachment from a domain | |
| 291 | - | gordon attachments remove app.mydomain.com postgres:18 | |
| 292 | - | ||
| 293 | - | # Remove from network group | |
| 294 | - | gordon attachments remove backend redis:7-alpine | |
| 295 | - | ||
| 296 | - | # Remote mode | |
| 297 | - | gordon attachments remove app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 298 | - | ``` | |
| 299 | - | ||
| 300 | - | ### Alias | |
| 301 | - | ||
| 302 | - | The `gordon attach` command is an alias for `gordon attachments`: | |
| 303 | - | ||
| 304 | - | ```bash | |
| 305 | - | gordon attach list | |
| 306 | - | gordon attach add app.mydomain.com postgres:18 | |
| 307 | - | gordon attach remove app.mydomain.com postgres:18 | |
| 308 | - | ``` | |
| 309 | - | ||
| 310 | - | ## Related | |
| 311 | - | ||
| 312 | - | - [Network Isolation](./network-isolation.md) | |
| 313 | - | - [Network Groups](./network-groups.md) | |
| 314 | - | - [Routes](./routes.md) | |
| 315 | - | - [CLI Commands](/docs/cli/index.md) | |
M docs/config/auth.md +12 -5
| @@ -87,16 +87,14 @@ Admin scopes (for remote CLI): | |||
| 87 | 87 | | Scope | Permission | | |
| 88 | 88 | |-------|------------| | |
| 89 | 89 | | `admin:*:*` | Full admin access | | |
| 90 | - | | `admin:routes:read` | Read-only routes access | | |
| 91 | - | | `admin:routes:write` | Routes write access | | |
| 90 | + | | `admin:apps:read` | Read-only apps access (list, show, diff, status) | | |
| 91 | + | | `admin:apps:write` | App mutations (apply, deploy, lifecycle, secrets) | | |
| 92 | 92 | | `admin:config:read` | Read-only config access | | |
| 93 | 93 | | `admin:config:write` | Config write access | | |
| 94 | 94 | | `admin:status:read` | Read-only status/health | | |
| 95 | 95 | | `admin:logs:read` | Read-only logs access | | |
| 96 | 96 | | `admin:volumes:read` | List Docker volumes | | |
| 97 | 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 | 99 | Examples: | |
| 102 | 100 | ||
| @@ -143,10 +141,19 @@ gordon auth token generate --subject temp --expiry 30d | |||
| 143 | 141 | ||
| 144 | 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 | 145 | - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`). | |
| 148 | 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 | 157 | Use this only when Gordon is intended for local machine usage. | |
| 151 | 158 | ||
| 152 | 159 | ## 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 | 3 | Gordon has two backup flows: | |
| 4 | 4 | ||
| 5 | 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 | 12 | ## Database backups | |
| 9 | 13 | ||
| @@ -64,19 +68,24 @@ keep = 14 | |||
| 64 | 68 | | `backups.volumes.s3.prefix` | `""` | Object key prefix | | |
| 65 | 69 | | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint | | |
| 66 | 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 | 75 | ```text | |
| 72 | - | <prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip | |
| 73 | - | <prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd | |
| 76 | + | <prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip | |
| 77 | + | <prefix>/apps/<app>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd | |
| 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 | 84 | ## Notes | |
| 77 | 85 | ||
| 78 | - | - Volume backups include named volumes mounted by Gordon-managed route or attachment containers. | |
| 79 | - | - Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded. | |
| 86 | + | - Volume backups archive the app's declared volumes that its backup declaration references. | |
| 87 | + | - Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up. | |
| 88 | + | - Database backups archive the app's declared PostgreSQL databases that its backup declaration references. | |
| 80 | 89 | - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop. | |
| 81 | 90 | - Automated volume restore is not part of the MVP. | |
| 82 | 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 | 94 | ## Related | |
| 86 | 95 | ||
| 87 | - | - [Attachments Configuration](./attachments.md) | |
| 96 | + | - [App Manifest](./apps.md) | |
| 88 | 97 | - [CLI Backup Command](../cli/backup.md) | |
| 89 | 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 | 80 | ## Related | |
| 81 | 81 | ||
| 82 | - | - [Routes Configuration](./routes.md) | |
| 82 | + | - [App Manifest](./apps.md) | |
| 83 | 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 | 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 | 10 | - Applies tag retention to registry repositories. | |
| 11 | 11 | - Preserves the `latest` tag. | |
| 12 | 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 | 17 | ## Configuration | |
| 17 | 18 | ||
| 18 | 19 | ```toml | |
| 19 | 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 | 23 | require_digest = false | |
| 22 | 24 | ||
| 23 | 25 | [images.prune] | |
| @@ -30,19 +32,32 @@ keep_last = 3 | |||
| 30 | 32 | ||
| 31 | 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"`. | | |
| 34 | - | | `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. | | |
| 35 | + | | `images.allowed_registries` | array | `[]` | Additional registry hostname+port entries. Docker Hub (`docker.io`, canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Include non-default ports, e.g. `"registry.example.com:5000"`. Hostnames are case-insensitive; one trailing dot and port `443` are canonicalized. This setting does not configure registry credentials. | | |
| 36 | + | | `images.require_digest` | bool | `false` | Require every image reference, including Gordon registry images, to use a valid `@sha256:<64 hex chars>` digest. | | |
| 35 | 37 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | |
| 36 | 38 | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | |
| 37 | 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 | 43 | ## Retention Behavior | |
| 40 | 44 | ||
| 41 | 45 | - `latest` is always preserved. | |
| 42 | 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 | 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 | 61 | ## Related | |
| 47 | 62 | ||
| 48 | 63 | - [CLI Images Command](../cli/images.md) | |
M docs/config/index.md +21 -72
| @@ -19,12 +19,11 @@ gordon_domain = "gordon.mydomain.com" | |||
| 19 | 19 | [entrypoints.edge] | |
| 20 | 20 | address = ":443" | |
| 21 | 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 | 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 | 60 | burst = 100 # Burst size | |
| 62 | 61 | 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 | 63 | # Container runtime profile | |
| 75 | 64 | [containers] | |
| 76 | 65 | security_profile = "compat" # compat or strict | |
| @@ -87,12 +76,8 @@ max_size = 100 # MB before rotation | |||
| 87 | 76 | max_backups = 3 # Old files to keep | |
| 88 | 77 | max_age = 28 # Days to keep | |
| 89 | 78 | ||
| 90 | - | [logging.container_logs] | |
| 91 | - | enabled = true | |
| 92 | - | dir = "~/.gordon/logs/containers" # Default location | |
| 93 | - | max_size = 100 | |
| 94 | - | max_backups = 3 | |
| 95 | - | max_age = 28 | |
| 79 | + | # Workload logs are streamed directly from the container runtime with | |
| 80 | + | # `gordon apps logs APP --service SERVICE`. | |
| 96 | 81 | ||
| 97 | 82 | # Telemetry (OpenTelemetry) | |
| 98 | 83 | [telemetry] | |
| @@ -101,42 +86,24 @@ endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint | |||
| 101 | 86 | auth_token = "" # Base64 user:password for Basic auth | |
| 102 | 87 | traces = true # Export traces | |
| 103 | 88 | metrics = true # Export metrics | |
| 104 | - | logs = true # Bridge zerolog to OTLP logs | |
| 89 | + | logs = true # Export Gordon, access, and app logs | |
| 105 | 90 | 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 | 92 | # Volume settings | |
| 112 | 93 | [volumes] | |
| 113 | 94 | auto_create = true # Auto-create from Dockerfile VOLUME | |
| 114 | 95 | prefix = "gordon" # Volume name prefix | |
| 115 | 96 | preserve = true # Keep volumes on container removal | |
| 116 | 97 | ||
| 117 | - | # Network isolation | |
| 98 | + | # Installation network policy (prefix filter for `gordon daemon networks`) | |
| 118 | 99 | [network_isolation] | |
| 119 | - | enabled = true # Per-app isolated networks | |
| 100 | + | enabled = true # Gordon-managed network policy | |
| 120 | 101 | network_prefix = "gordon" # Network name prefix | |
| 121 | 102 | internal = false # Set true to block direct egress from isolated networks | |
| 122 | 103 | ||
| 123 | - | # Auto-route | |
| 124 | - | [auto_route] | |
| 125 | - | enabled = false # Auto-create routes from image names | |
| 126 | - | ||
| 127 | - | # Routes (required) | |
| 128 | - | [routes] | |
| 129 | - | "app.mydomain.com" = "myapp:latest" | |
| 130 | - | "api.mydomain.com" = "myapi:v2.1.0" | |
| 131 | - | ||
| 132 | - | # Network groups | |
| 133 | - | [network_groups] | |
| 134 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 135 | - | ||
| 136 | - | # Attachments | |
| 137 | - | [attachments] | |
| 138 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 139 | - | "backend" = ["rabbitmq:latest"] | |
| 104 | + | # REMOVED in v3 (declare apps in standalone files, see ./apps.md): | |
| 105 | + | # [routes], [attachments], [network_groups], | |
| 106 | + | # [service_routes], [auto_route], [previews] | |
| 140 | 107 | ||
| 141 | 108 | # Backups | |
| 142 | 109 | [backups] | |
| @@ -152,8 +119,8 @@ monthly = 12 | |||
| 152 | 119 | ||
| 153 | 120 | # Images | |
| 154 | 121 | [images] | |
| 155 | - | allowed_registries = [] # Explicit external registries allowed for deploy/attachments | |
| 156 | - | require_digest = false # Require digests for allowlisted external registries | |
| 122 | + | allowed_registries = [] # Additional exact registry hostname+port entries | |
| 123 | + | require_digest = false # Require SHA-256 digests for every image registry | |
| 157 | 124 | ||
| 158 | 125 | [images.prune] | |
| 159 | 126 | enabled = false | |
| @@ -161,6 +128,8 @@ schedule = "daily" | |||
| 161 | 128 | 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 | 133 | ## Configuration Sections | |
| 165 | 134 | ||
| 166 | 135 | | Section | Description | Documentation | | |
| @@ -168,19 +137,14 @@ keep_last = 3 | |||
| 168 | 137 | | `[server]` | Core server settings | [Server](./server.md) | | |
| 169 | 138 | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | | |
| 170 | 139 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | |
| 171 | - | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 172 | 140 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 173 | 141 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | |
| 174 | - | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| 175 | 142 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | |
| 176 | - | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | |
| 177 | - | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 178 | - | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 143 | + | | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) | | |
| 179 | 144 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 180 | 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) | | |
| 182 | - | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 183 | - | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 146 | + | | App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) | | |
| 147 | + | | `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) | | |
| 184 | 148 | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | |
| 185 | 149 | | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) | | |
| 186 | 150 | ||
| @@ -200,13 +164,6 @@ keep_last = 3 | |||
| 200 | 164 | | `api.rate_limit.per_ip_rps` | `50` | | |
| 201 | 165 | | `api.rate_limit.burst` | `100` | | |
| 202 | 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 | 167 | | `containers.security_profile` | `"compat"` | | |
| 211 | 168 | | `logging.level` | `"info"` | | |
| 212 | 169 | | `logging.format` | `"console"` | | |
| @@ -214,13 +171,11 @@ keep_last = 3 | |||
| 214 | 171 | | `logging.file.max_size` | `100` | | |
| 215 | 172 | | `logging.file.max_backups` | `3` | | |
| 216 | 173 | | `logging.file.max_age` | `28` | | |
| 217 | - | | `logging.container_logs.enabled` | `true` | | |
| 218 | 174 | | `volumes.auto_create` | `true` | | |
| 219 | 175 | | `volumes.prefix` | `"gordon"` | | |
| 220 | 176 | | `volumes.preserve` | `true` | | |
| 221 | 177 | | `network_isolation.enabled` | `true` | | |
| 222 | 178 | | `network_isolation.internal` | `false` | | |
| 223 | - | | `auto_route.enabled` | `false` | | |
| 224 | 179 | | `backups.enabled` | `false` | | |
| 225 | 180 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | |
| 226 | 181 | | `images.allowed_registries` | `[]` | | |
| @@ -236,14 +191,14 @@ keep_last = 3 | |||
| 236 | 191 | | `telemetry.logs` | `true` | | |
| 237 | 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 | 196 | ## Hot Reload | |
| 242 | 197 | ||
| 243 | 198 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | |
| 244 | 199 | ||
| 245 | 200 | ```bash | |
| 246 | - | gordon reload | |
| 201 | + | gordon daemon reload | |
| 247 | 202 | ``` | |
| 248 | 203 | ||
| 249 | 204 | ### Hot-reloaded (no restart needed) | |
| @@ -256,7 +211,6 @@ gordon reload | |||
| 256 | 211 | | `server.max_proxy_response_size` | | |
| 257 | 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 | 215 | ### Requires restart | |
| 262 | 216 | ||
| @@ -267,11 +221,6 @@ gordon reload | |||
| 267 | 221 | | `server.max_blob_chunk_size` | | |
| 268 | 222 | | `server.max_blob_size` | | |
| 269 | 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 | 225 | ## Environment Variable Override | |
| 277 | 226 | ||
| @@ -286,7 +235,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 286 | 235 | ## Related | |
| 287 | 236 | ||
| 288 | 237 | - [Server Configuration](./server.md) | |
| 289 | - | - [Routes Configuration](./routes.md) | |
| 238 | + | - [App Manifest](./apps.md) | |
| 290 | 239 | - [External Routes](./external-routes.md) | |
| 291 | 240 | - [Standalone Services](./services.md) | |
| 292 | 241 | - [Traffic Plane](./traffic.md) | |
M docs/config/logging.md +21 -92
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # 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 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -16,13 +16,6 @@ max_size = 100 | |||
| 16 | 16 | max_backups = 3 | |
| 17 | 17 | 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 | 19 | [logging.access_log] | |
| 27 | 20 | enabled = false | |
| 28 | 21 | format = "json" | |
| @@ -44,24 +37,19 @@ syslog_identifier = "gordon-access" | |||
| 44 | 37 | ||
| 45 | 38 | | Option | Type | Default | Description | | |
| 46 | 39 | |--------|------|---------|-------------| | |
| 47 | - | | `file.enabled` | bool | `false` | Enable file-based logging | | |
| 48 | - | | `file.path` | string | - | Path to main log file | | |
| 40 | + | | `file.enabled` | bool | `false` | Enable process file logging | | |
| 41 | + | | `file.path` | string | `{data_dir}/logs/gordon.log` | Process log path | | |
| 49 | 42 | | `file.max_size` | int | `100` | Max file size in MB before rotation | | |
| 50 | 43 | | `file.max_backups` | int | `3` | Number of old files to keep | | |
| 51 | 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. Keep | |
| 54 | - | `logging.file.enabled` set to `true` if you need process log streaming. | |
| 46 | + | The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming. | |
| 55 | 47 | ||
| 56 | - | ### Container Logs | |
| 48 | + | ### Workload Logs | |
| 57 | 49 | ||
| 58 | - | | Option | Type | Default | Description | | |
| 59 | - | |--------|------|---------|-------------| | |
| 60 | - | | `container_logs.enabled` | bool | `true` | Collect container stdout/stderr | | |
| 61 | - | | `container_logs.dir` | string | `{data_dir}/logs/containers` | Directory for container logs | | |
| 62 | - | | `container_logs.max_size` | int | `100` | Max file size in MB | | |
| 63 | - | | `container_logs.max_backups` | int | `3` | Old files to keep | | |
| 64 | - | | `container_logs.max_age` | int | `28` | Days to keep files | | |
| 50 | + | `gordon apps logs APP --service SERVICE` reads stdout and stderr directly from the container runtime. Gordon does not persist workload logs to files; configure retention in the container runtime's logging driver. | |
| 51 | + | ||
| 52 | + | The accepted `logging.container_logs` configuration fields are currently not connected to a production log sink and do not create files. | |
| 65 | 53 | ||
| 66 | 54 | ### Access Log | |
| 67 | 55 | ||
| @@ -89,31 +77,9 @@ Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingesti | |||
| 89 | 77 | | `warn` | Warnings | | |
| 90 | 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 | 80 | ## Log Rotation | |
| 105 | 81 | ||
| 106 | - | Gordon uses automatic log rotation: | |
| 107 | - | ||
| 108 | - | 1. **Size-based**: Rotates when file exceeds `max_size` MB | |
| 109 | - | 2. **Age-based**: Removes files older than `max_age` days | |
| 110 | - | 3. **Count-based**: Keeps only `max_backups` old files | |
| 111 | - | 4. **Compression**: Old files are gzip compressed | |
| 112 | - | ||
| 113 | - | Example with defaults: | |
| 114 | - | - Log grows to 100MB → rotated to `gordon.log.1` | |
| 115 | - | - After 3 rotations → oldest is compressed | |
| 116 | - | - Files older than 28 days → deleted | |
| 82 | + | Process file logs and file-based access logs rotate by size and retain files by count and age. Old files are compressed. | |
| 117 | 83 | ||
| 118 | 84 | ## Examples | |
| 119 | 85 | ||
| @@ -131,13 +97,6 @@ max_size = 10 | |||
| 131 | 97 | max_backups = 2 | |
| 132 | 98 | 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 | 100 | [logging.access_log] | |
| 142 | 101 | enabled = true | |
| 143 | 102 | format = "json" | |
| @@ -160,12 +119,11 @@ max_size = 100 | |||
| 160 | 119 | max_backups = 10 | |
| 161 | 120 | max_age = 90 | |
| 162 | 121 | ||
| 163 | - | [logging.container_logs] | |
| 122 | + | [logging.access_log] | |
| 164 | 123 | enabled = true | |
| 165 | - | dir = "~/.gordon/logs/containers" | |
| 166 | - | max_size = 100 | |
| 167 | - | max_backups = 10 | |
| 168 | - | max_age = 90 | |
| 124 | + | format = "json" | |
| 125 | + | output = "journald" | |
| 126 | + | syslog_identifier = "gordon-access" | |
| 169 | 127 | ``` | |
| 170 | 128 | ||
| 171 | 129 | ### Minimal (Console Only) | |
| @@ -177,60 +135,31 @@ format = "console" | |||
| 177 | 135 | ||
| 178 | 136 | [logging.file] | |
| 179 | 137 | enabled = false | |
| 180 | - | ||
| 181 | - | [logging.container_logs] | |
| 182 | - | enabled = true | |
| 183 | 138 | ``` | |
| 184 | 139 | ||
| 185 | 140 | ## Viewing Logs | |
| 186 | 141 | ||
| 187 | - | ### Gordon Logs | |
| 142 | + | ### Gordon Process Logs | |
| 188 | 143 | ||
| 189 | 144 | ```bash | |
| 190 | - | # Using gordon logs command | |
| 191 | - | gordon logs -f # Follow logs | |
| 192 | - | gordon logs -n 100 # Last 100 lines | |
| 193 | - | ||
| 194 | - | # Direct file access | |
| 145 | + | gordon daemon logs -f | |
| 146 | + | gordon daemon logs -n 100 | |
| 195 | 147 | tail -f ~/.gordon/logs/gordon.log | |
| 196 | - | ||
| 197 | - | # With journalctl (if using systemd) | |
| 198 | 148 | journalctl --user -u gordon -f | |
| 199 | 149 | ``` | |
| 200 | 150 | ||
| 201 | - | ### Container Logs | |
| 151 | + | ### Workload Logs | |
| 202 | 152 | ||
| 203 | 153 | ```bash | |
| 204 | - | # View container logs | |
| 205 | - | tail -f ~/.gordon/logs/containers/app_mydomain_com.log | |
| 206 | - | ||
| 207 | - | # All container logs | |
| 208 | - | ls ~/.gordon/logs/containers/ | |
| 154 | + | gordon apps logs blog --service web | |
| 155 | + | gordon apps logs blog --service web --follow | |
| 209 | 156 | ``` | |
| 210 | 157 | ||
| 211 | - | ## Log Format | |
| 212 | - | ||
| 213 | - | ### Console Format | |
| 214 | - | ||
| 215 | - | ``` | |
| 216 | - | 2024-01-15T10:30:00Z INF container deployed domain=app.mydomain.com image=myapp:latest | |
| 217 | - | 2024-01-15T10:30:01Z INF proxy routing updated route=app.mydomain.com | |
| 218 | - | ``` | |
| 219 | - | ||
| 220 | - | ### JSON Format | |
| 221 | - | ||
| 222 | - | ```json | |
| 223 | - | {"level":"info","time":"2024-01-15T10:30:00Z","message":"container deployed","domain":"app.mydomain.com","image":"myapp:latest"} | |
| 224 | - | {"level":"info","time":"2024-01-15T10:30:01Z","message":"proxy routing updated","route":"app.mydomain.com"} | |
| 225 | - | ``` | |
| 158 | + | These commands stream from the container runtime; they do not read Gordon-managed workload log files. | |
| 226 | 159 | ||
| 227 | 160 | ## Security | |
| 228 | 161 | ||
| 229 | - | Log files are created with secure permissions: | |
| 230 | - | - Directories: `0700` (owner only) | |
| 231 | - | - Files: `0600` (owner only) | |
| 232 | - | ||
| 233 | - | Sensitive values (passwords, tokens) are automatically redacted. | |
| 162 | + | Process and access log files are created with owner-only permissions. Gordon redacts common credential patterns when serving logs, but application output may still contain sensitive data. Restrict process-log, journal, runtime, and `admin:logs:read` access. | |
| 234 | 163 | ||
| 235 | 164 | ## 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 | 1 | # 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 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -11,177 +11,40 @@ network_prefix = "gordon" | |||
| 11 | 11 | 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 | 14 | ## Options | |
| 25 | 15 | ||
| 26 | 16 | | Option | Type | Default | Description | | |
| 27 | 17 | |--------|------|---------|-------------| | |
| 28 | - | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | | |
| 29 | - | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | |
| 30 | - | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. | | |
| 31 | - | ||
| 32 | - | ## How It Works | |
| 33 | - | ||
| 34 | - | When network isolation is enabled, each application gets its own Docker network: | |
| 35 | - | ||
| 36 | - | ``` | |
| 37 | - | [network_isolation] | |
| 38 | - | enabled = true | |
| 39 | - | network_prefix = "gordon" | |
| 40 | - | ||
| 41 | - | [routes] | |
| 42 | - | "app.mydomain.com" = "myapp:latest" | |
| 43 | - | "api.mydomain.com" = "myapi:latest" | |
| 44 | - | ``` | |
| 45 | - | ||
| 46 | - | Creates two isolated networks: | |
| 47 | - | - `gordon-app-mydomain-com` | |
| 48 | - | - `gordon-api-mydomain-com` | |
| 49 | - | ||
| 50 | - | ## Network Naming | |
| 51 | - | ||
| 52 | - | Networks are named: `{prefix}-{domain-with-dashes}` | |
| 53 | - | ||
| 54 | - | | Domain | Network Name | | |
| 55 | - | |--------|--------------| | |
| 56 | - | | `app.mydomain.com` | `gordon-app-mydomain-com` | | |
| 57 | - | | `api.company.io` | `gordon-api-company-io` | | |
| 58 | - | | `staging.app.dev` | `gordon-staging-app-dev` | | |
| 59 | - | ||
| 60 | - | ## Security Benefits | |
| 61 | - | ||
| 62 | - | ### Without Network Isolation | |
| 63 | - | ||
| 64 | - | All containers can potentially communicate: | |
| 65 | - | ||
| 66 | - | ``` | |
| 67 | - | ┌───────────────────────────────────────┐ | |
| 68 | - | │ Default Bridge Network │ | |
| 69 | - | │ │ | |
| 70 | - | │ App A ←──────→ App B ←──────→ App C │ | |
| 71 | - | │ ↕ ↕ ↕ │ | |
| 72 | - | │ DB A ←──────→ DB B ←──────→ DB C │ | |
| 73 | - | │ │ | |
| 74 | - | └───────────────────────────────────────┘ | |
| 75 | - | ``` | |
| 76 | - | ||
| 77 | - | ### With Network Isolation | |
| 78 | - | ||
| 79 | - | Each app is isolated with its dependencies: | |
| 80 | - | ||
| 81 | - | ``` | |
| 82 | - | ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ | |
| 83 | - | │ gordon-app-a │ │ gordon-app-b │ │ gordon-app-c │ | |
| 84 | - | │ │ │ │ │ │ | |
| 85 | - | │ App A ←→ DB A │ │ App B ←→ DB B │ │ App C ←→ DB C │ | |
| 86 | - | │ │ │ │ │ │ | |
| 87 | - | └─────────────────┘ └─────────────────┘ └─────────────────┘ | |
| 88 | - | ↑ ↑ ↑ | |
| 89 | - | └───────── No direct communication ─────┘ | |
| 90 | - | ``` | |
| 91 | - | ||
| 92 | - | ## Service Discovery | |
| 93 | - | ||
| 94 | - | Within an isolated network, services are discoverable by name: | |
| 18 | + | | `enabled` | bool | `true` | Enable Gordon-managed network policy | | |
| 19 | + | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon networks` | | |
| 20 | + | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. | | |
| 95 | 21 | ||
| 96 | - | ```toml | |
| 97 | - | [network_isolation] | |
| 98 | - | enabled = true | |
| 99 | - | ||
| 100 | - | [attachments] | |
| 101 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Your application connects using simple hostnames: | |
| 105 | - | ||
| 106 | - | ```python | |
| 107 | - | # These work within the isolated network | |
| 108 | - | db = connect("postgresql://postgres:5432/mydb") | |
| 109 | - | cache = connect("redis://redis:6379") | |
| 110 | - | ``` | |
| 111 | - | ||
| 112 | - | ## Examples | |
| 113 | - | ||
| 114 | - | ### Basic Isolation | |
| 115 | - | ||
| 116 | - | ```toml | |
| 117 | - | [network_isolation] | |
| 118 | - | enabled = true | |
| 119 | - | ||
| 120 | - | [routes] | |
| 121 | - | "app.mydomain.com" = "myapp:latest" | |
| 122 | - | "api.mydomain.com" = "myapi:latest" | |
| 123 | - | ||
| 124 | - | [attachments] | |
| 125 | - | "app.mydomain.com" = ["app-postgres:latest"] | |
| 126 | - | "api.mydomain.com" = ["api-postgres:latest"] | |
| 127 | - | ``` | |
| 128 | - | ||
| 129 | - | Each app gets its own network with its own database. | |
| 130 | - | ||
| 131 | - | ### Production Configuration | |
| 132 | - | ||
| 133 | - | ```toml | |
| 134 | - | [network_isolation] | |
| 135 | - | enabled = true | |
| 136 | - | network_prefix = "prod" | |
| 137 | - | ||
| 138 | - | [routes] | |
| 139 | - | "app.company.com" = "company-app:v2.1.0" | |
| 140 | - | "api.company.com" = "company-api:v1.5.0" | |
| 141 | - | "admin.company.com" = "admin-panel:v1.0.0" | |
| 142 | - | ``` | |
| 143 | - | ||
| 144 | - | Creates networks: | |
| 145 | - | - `prod-app-company-com` | |
| 146 | - | - `prod-api-company-com` | |
| 147 | - | - `prod-admin-company-com` | |
| 148 | - | ||
| 149 | - | ### Shared Services with Network Groups | |
| 150 | - | ||
| 151 | - | When apps need to communicate, use network groups: | |
| 22 | + | Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership. | |
| 152 | 23 | ||
| 153 | - | ```toml | |
| 154 | - | [network_isolation] | |
| 155 | - | enabled = true | |
| 24 | + | ## Service-to-Service Networking | |
| 156 | 25 | ||
| 157 | - | [network_groups] | |
| 158 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 26 | + | Every app container joins an incarnation-owned private network. Services of the same app communicate over that network and can resolve each other by service alias. | |
| 159 | 27 | ||
| 160 | - | [attachments] | |
| 161 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 162 | - | ``` | |
| 28 | + | Different apps are isolated by default: a container on one app network cannot reach another app's network. Cross-app communication happens only when both services declare the same `[[network.shared]]` membership, which attaches the explicitly enrolled containers to a shared network. | |
| 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 | 32 | ## Inspecting Networks | |
| 167 | 33 | ||
| 168 | - | View created networks: | |
| 34 | + | View Gordon-managed networks: | |
| 169 | 35 | ||
| 170 | 36 | ```bash | |
| 37 | + | gordon daemon networks | |
| 171 | 38 | docker network ls | grep gordon | |
| 172 | - | # gordon-app-mydomain-com | |
| 173 | - | # gordon-api-mydomain-com | |
| 174 | - | # gordon-backend | |
| 175 | 39 | ``` | |
| 176 | 40 | ||
| 177 | 41 | Inspect a network: | |
| 178 | 42 | ||
| 179 | 43 | ```bash | |
| 180 | - | docker network inspect gordon-app-mydomain-com | |
| 44 | + | docker network inspect <network-name> | |
| 181 | 45 | ``` | |
| 182 | 46 | ||
| 183 | 47 | ## Related | |
| 184 | 48 | ||
| 185 | - | - [Network Groups](./network-groups.md) | |
| 186 | - | - [Attachments](./attachments.md) | |
| 49 | + | - [App Manifest](./apps.md) | |
| 187 | 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 | 83 | max_backups = 3 # Number of old files to keep | |
| 84 | 84 | max_age = 28 # Days to keep old files | |
| 85 | 85 | ||
| 86 | - | [logging.container_logs] | |
| 87 | - | enabled = true # Enable container log collection | |
| 88 | - | dir = "" # Log directory (default: {data_dir}/container-logs) | |
| 89 | - | max_size = 100 # Max size in MB before rotation | |
| 90 | - | max_backups = 3 # Number of old files to keep | |
| 91 | - | max_age = 28 # Days to keep old files | |
| 86 | + | # Workload logs are streamed from the container runtime with | |
| 87 | + | # `gordon apps logs APP --service SERVICE`. The accepted | |
| 88 | + | # logging.container_logs fields are not connected to a production file sink. | |
| 92 | 89 | ||
| 93 | 90 | [logging.access_log] | |
| 94 | 91 | enabled = false # Dedicated HTTP access log for reverse-proxy traffic | |
| @@ -110,45 +107,21 @@ endpoint = "" # OTLP HTTP endpoint URL | |||
| 110 | 107 | auth_token = "" # Base64-encoded user:password for Basic auth | |
| 111 | 108 | traces = true # Export distributed traces | |
| 112 | 109 | metrics = true # Export metrics | |
| 113 | - | logs = true # Bridge zerolog output to OTLP logs | |
| 110 | + | logs = true # Export Gordon, access, and app logs | |
| 114 | 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 | 114 | # CONTAINERS | |
| 136 | 115 | # ============================================================================= | |
| 137 | 116 | [containers] | |
| 138 | 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 | 120 | # NETWORK ISOLATION | |
| 148 | 121 | # ============================================================================= | |
| 149 | 122 | [network_isolation] | |
| 150 | - | enabled = true # Enable per-app Docker networks | |
| 151 | - | network_prefix = "gordon" # Prefix for created networks | |
| 123 | + | enabled = true # Installation network policy for Gordon-managed networks | |
| 124 | + | network_prefix = "gordon" # Prefix filter for `gordon daemon networks` | |
| 152 | 125 | internal = false # Create Docker internal networks (blocks direct egress) | |
| 153 | 126 | ||
| 154 | 127 | # ============================================================================= | |
| @@ -160,12 +133,38 @@ prefix = "gordon" # Volume name prefix | |||
| 160 | 133 | preserve = true # Keep volumes when containers are removed | |
| 161 | 134 | ||
| 162 | 135 | # ============================================================================= | |
| 163 | - | # ROUTES | |
| 136 | + | # ADMINISTRATIVE APP BIND MOUNTS | |
| 137 | + | # ============================================================================= | |
| 138 | + | # A named policy is the only way an app manifest may reference a host path. | |
| 139 | + | # App manifests declare [[services.<name>.bind]] with name = "<mount>"; direct host | |
| 140 | + | # paths are always rejected, and the policy's allowlists are exact and | |
| 141 | + | # non-empty. read_only on the policy and readonly on the bind both force | |
| 142 | + | # read-only: either side wins and a bind never weakens its policy. | |
| 143 | + | # [app_mounts.app-logs] | |
| 144 | + | # source = "/srv/gordon/host-logs" # Required; absolute, normalized | |
| 145 | + | # read_only = true # Force read-only for this mount | |
| 146 | + | # allowed_apps = ["metrics-agent"] # Required; exact, non-empty | |
| 147 | + | # allowed_services = ["web"] # Required; exact, non-empty | |
| 148 | + | # root = "/srv/gordon" # Optional; default: parent of source | |
| 149 | + | ||
| 150 | + | # ============================================================================= | |
| 151 | + | # ADMINISTRATIVE APP DEVICES (CDI) | |
| 164 | 152 | # ============================================================================= | |
| 165 | - | [routes] | |
| 166 | - | # "domain.com" = { image = "image:tag" } | |
| 167 | - | # "insecure.domain.com" = { image = "image:tag", https = false } | |
| 168 | - | # Legacy "http://domain.com" keys are read for compatibility and rewritten on save. | |
| 153 | + | # A named device grant is the only way an app manifest may request host | |
| 154 | + | # devices. Manifests declare devices = ["<name>"]; raw /dev paths are | |
| 155 | + | # always rejected, and the policy's allowlists are exact and non-empty. | |
| 156 | + | # Aggregate "=all" CDI selectors are rejected: name every device | |
| 157 | + | # explicitly. Device-bearing creates require Podman 5.4+ or Docker 28.3+ | |
| 158 | + | # with native CDI configured. | |
| 159 | + | # [app_devices.transcode-gpu] | |
| 160 | + | # cdi = ["example.com/gpu=GPU-device-uuid"] # Required; explicit, non-empty | |
| 161 | + | # allowed_apps = ["video"] # Required; exact, non-empty | |
| 162 | + | # allowed_services = ["transcoder"] # Required; exact, non-empty | |
| 163 | + | ||
| 164 | + | # REMOVED in v3: [routes], [attachments], [network_groups], | |
| 165 | + | # app-like [[services]], [service_routes], [auto_route], [previews]. | |
| 166 | + | # Declare apps in standalone files (see ./apps.md). The standalone | |
| 167 | + | # [[services]] table below stays valid for installation-level L4 workloads. | |
| 169 | 168 | ||
| 170 | 169 | # ============================================================================= | |
| 171 | 170 | # EXTERNAL ROUTES | |
| @@ -195,18 +194,6 @@ preserve = true # Keep volumes when containers are | |||
| 195 | 194 | # publish = "127.0.0.1:38016" | |
| 196 | 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 | 198 | # BACKUPS | |
| 212 | 199 | # ============================================================================= | |
| @@ -225,6 +212,8 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 225 | 212 | # IMAGES | |
| 226 | 213 | # ============================================================================= | |
| 227 | 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 | 217 | allowed_registries = [] | |
| 229 | 218 | require_digest = false | |
| 230 | 219 | ||
| @@ -265,7 +254,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 265 | 254 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 266 | 255 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 267 | 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 | 258 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 270 | 259 | | `auth.token_expiry` | `"30d"` | 30 days | | |
| 271 | 260 | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | |
| @@ -280,10 +269,6 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 280 | 269 | | `logging.file.max_size` | `100` | 100 MB | | |
| 281 | 270 | | `logging.file.max_backups` | `3` | Keep 3 old files | | |
| 282 | 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 | 272 | | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled | | |
| 288 | 273 | | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) | | |
| 289 | 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 | 282 | | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth | | |
| 298 | 283 | | `telemetry.traces` | `true` | Export distributed traces | | |
| 299 | 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 | 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 | 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 | 288 | | `network_isolation.enabled` | `true` | Network isolation enabled | | |
| 312 | 289 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| 313 | 290 | | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress | | |
| 314 | 291 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 315 | 292 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 316 | 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 | 302 | | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs | | |
| 318 | 303 | | `services[].image` | none | Container image for enabled standalone services | | |
| 319 | 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 | 327 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | |
| 343 | 328 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | |
| 344 | 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 | | |
| 346 | - | | `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries | | |
| 330 | + | | `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. | | |
| 331 | + | | `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon | | |
| 347 | 332 | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | |
| 348 | 333 | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | |
| 349 | 334 | | `images.prune.keep_last` | `3` | Number of recent tags kept per repository | | |
| 350 | 335 | ||
| 351 | 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 | 351 | ## Environment Variables | |
| 354 | 352 | ||
| 355 | 353 | All configuration options can be set via environment variables using the pattern: | |
| @@ -419,5 +417,6 @@ gordon serve | |||
| 419 | 417 | - [Telemetry](./telemetry.md) | |
| 420 | 418 | - [Network Isolation](./network-isolation.md) | |
| 421 | 419 | - [Volumes](./volumes.md) | |
| 420 | + | - [App Manifest](./apps.md) | |
| 422 | 421 | - [Standalone Services](./services.md) | |
| 423 | 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 | 1 | # 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 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -52,12 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store | |||
| 52 | 52 | - Standard Unix tooling | |
| 53 | 53 | - 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 | 55 | ### SOPS | |
| 62 | 56 | ||
| 63 | 57 | Uses Mozilla SOPS for encrypted file-based secrets: | |
| @@ -77,13 +71,6 @@ brew install sops # macOS | |||
| 77 | 71 | 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 | 74 | **Benefits:** | |
| 88 | 75 | - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP) | |
| 89 | 76 | - YAML/JSON file encryption | |
| @@ -94,15 +81,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 94 | 81 | - Path traversal (`..`) is blocked | |
| 95 | 82 | - 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 | 84 | ### Unsafe (Development Only) | |
| 107 | 85 | ||
| 108 | 86 | Stores secrets as plain text files: | |
| @@ -120,11 +98,6 @@ secrets_backend = "unsafe" | |||
| 120 | 98 | │ └── 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 | 101 | **Usage:** | |
| 129 | 102 | ```bash | |
| 130 | 103 | # Create secret | |
| @@ -134,25 +107,15 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret | |||
| 134 | 107 | ||
| 135 | 108 | > **Warning:** Only use for local development. Secrets are stored in plain text. | |
| 136 | 109 | ||
| 137 | - | ## Secret Provider Syntax | |
| 138 | - | ||
| 139 | - | In environment files, reference secrets using provider syntax: | |
| 110 | + | ## App Secret Values | |
| 140 | 111 | ||
| 141 | - | ### Pass Provider | |
| 112 | + | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | |
| 142 | 113 | ||
| 143 | 114 | ```bash | |
| 144 | - | # ${pass:<path>} | |
| 145 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |
| 146 | - | API_KEY=${pass:myapp/api-key} | |
| 115 | + | gordon apps secrets set blog --service web DATABASE_URL=... | |
| 147 | 116 | ``` | |
| 148 | 117 | ||
| 149 | - | ### SOPS Provider | |
| 150 | - | ||
| 151 | - | ```bash | |
| 152 | - | # ${sops:<file>:<key.path>} | |
| 153 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 154 | - | API_SECRET=${sops:production.yaml:api.secret.key} | |
| 155 | - | ``` | |
| 118 | + | Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md). | |
| 156 | 119 | ||
| 157 | 120 | ## Examples | |
| 158 | 121 | ||
| @@ -182,29 +145,10 @@ enabled = false | |||
| 182 | 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 | 148 | ## Security Recommendations | |
| 205 | 149 | ||
| 206 | 150 | 1. **Production**: Always use `pass` or `sops` backend | |
| 207 | - | 2. **Never commit**: Don't commit unencrypted secrets to git | |
| 151 | + | 2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values) | |
| 208 | 152 | 3. **Rotate regularly**: Regenerate tokens and passwords periodically | |
| 209 | 153 | 4. **Least privilege**: Use separate secrets per environment | |
| 210 | 154 | 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 | 156 | ## Related | |
| 213 | 157 | ||
| 214 | 158 | - [Authentication](./auth.md) | |
| 215 | - | - [Environment Variables](./env.md) | |
| 159 | + | - [App Manifest](./apps.md) | |
| 216 | 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 | 24 | 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 | 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 | 32 | ## 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 | 38 | Use dedicated admin scopes: | |
| 36 | 39 | ||
| 37 | 40 | - `admin:volumes:read` for listing volumes. | |
| 38 | 41 | - `admin:volumes:write` for prune operations. | |
| 39 | 42 | ||
| 40 | - | ## Pass migration plaintext handling | |
| 43 | + | ## Administrative app bind mounts | |
| 44 | + | ||
| 45 | + | App manifests cannot name host paths. A host bind exists only when the operator declares a named policy in `gordon.toml`: | |
| 46 | + | ||
| 47 | + | ```toml | |
| 48 | + | [app_mounts.app-logs] | |
| 49 | + | source = "/srv/gordon/host-logs" | |
| 50 | + | read_only = true | |
| 51 | + | allowed_apps = ["metrics-agent"] # exact, non-empty | |
| 52 | + | allowed_services = ["web"] # exact, non-empty | |
| 53 | + | root = "/srv/gordon" # optional boundary | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | The manifest references only the policy name: `[[services.<name>.bind]] name = "app-logs"` with an absolute container `path`. | |
| 57 | + | ||
| 58 | + | - `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form. | |
| 59 | + | - `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused. | |
| 60 | + | - Read-only precedence: `read_only` on the policy or `readonly` on the manifest bind forces the mount read-only; a manifest never weakens its policy. | |
| 61 | + | - Destinations must be absolute, normalized, and outside reserved container paths (`/`, `/proc`, `/sys`, `/dev`, `/boot`, and their children). | |
| 62 | + | - The policy is re-resolved immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed. | |
| 63 | + | - `gordon serve` reload republishes validated policies atomically. An edit that fails validation is rejected and the previous policies stay live. | |
| 64 | + | - Gordon never creates, deletes, chowns, backs up, or prunes the host path; ownership, permissions, and backup remain the operator's responsibility. | |
| 65 | + | ||
| 66 | + | ## Administrative app devices (CDI) | |
| 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 | 86 | ## 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 | 90 | ```toml | |
| 49 | 91 | [images] | |
| 50 | - | allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"] | |
| 92 | + | allowed_registries = ["registry.internal:5000"] | |
| 51 | 93 | require_digest = true | |
| 52 | 94 | ``` | |
| 53 | 95 | ||
| 54 | - | - Empty `allowed_registries` rejects explicit external registries. | |
| 55 | - | - `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected. | |
| 56 | - | - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`. | |
| 57 | - | - Include ports in allowlist entries when the registry uses a non-default port. | |
| 96 | + | - `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent. | |
| 97 | + | - Other registries are accepted only when their exact hostname+port is configured. Allowlisting does not configure credentials; external resolution and pulls are anonymous unless the runtime already has suitable access. | |
| 98 | + | - `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`. | |
| 99 | + | - The allowlist restricts hostnames, not resolved IPs. It cannot prove a DNS hostname is non-private and does not replace firewall or runtime egress controls. | |
| 58 | 100 | ||
| 59 | 101 | ## Smart TCP Raw Fallback | |
| 60 | 102 | ||
| @@ -95,7 +137,28 @@ enabled = true | |||
| 95 | 137 | 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 | 163 | ## Container runtime profile | |
| 101 | 164 | ||
| @@ -114,6 +177,5 @@ Use `strict` for images designed to write only to mounted volumes and run withou | |||
| 114 | 177 | - [Auth](./auth.md) | |
| 115 | 178 | - [Images](./images.md) | |
| 116 | 179 | - [Network Isolation](./network-isolation.md) | |
| 117 | - | - [Deploy](./deploy.md) | |
| 118 | 180 | - [Volumes](./volumes.md) | |
| 119 | 181 | - [Reference](./reference.md) | |
M docs/config/server.md +8 -12
| @@ -32,7 +32,7 @@ protocol = "smart_tcp" | |||
| 32 | 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 33 | 33 | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | | |
| 34 | 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 | 36 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 37 | 37 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 38 | 38 | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | |
| @@ -102,7 +102,7 @@ Challenge behavior: | |||
| 102 | 102 | ||
| 103 | 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 | 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 | 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 | 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 | 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 | 133 | #### HTTP to HTTPS redirect | |
| 134 | 134 | ||
| @@ -187,7 +187,7 @@ gordon_domain = "gordon.mydomain.com" | |||
| 187 | 187 | This domain is used for: | |
| 188 | 188 | - Docker login and image push/pull operations | |
| 189 | 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 | 191 | - Authentication endpoints (`/auth/*`) | |
| 192 | 192 | ||
| 193 | 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 | 220 | ├── registry/ # Container images and manifests | |
| 221 | 221 | │ ├── blobs/ | |
| 222 | 222 | │ └── manifests/ | |
| 223 | - | ├── env/ # Environment files per domain | |
| 224 | - | │ ├── app_mydomain_com.env | |
| 225 | - | │ └── api_mydomain_com.env | |
| 226 | - | ├── logs/ # Application and container logs | |
| 223 | + | ├── logs/ # Gordon process and file-based access logs | |
| 227 | 224 | │ ├── gordon.log | |
| 228 | - | │ ├── proxy.log | |
| 229 | - | │ └── containers/ | |
| 225 | + | │ └── access.log | |
| 230 | 226 | └── secrets/ # Secrets (unsafe backend only) | |
| 231 | 227 | ``` | |
| 232 | 228 | ||
| @@ -430,6 +426,6 @@ sudo firewall-cmd --reload | |||
| 430 | 426 | ||
| 431 | 427 | - [Configuration Overview](./index.md) | |
| 432 | 428 | - [Installation Guide](../installation.md) | |
| 433 | - | - [Routes Configuration](./routes.md) | |
| 429 | + | - [App Manifest](./apps.md) | |
| 434 | 430 | - [Standalone Services](./services.md) | |
| 435 | 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 | 92 | - [Traffic Plane Configuration](./traffic.md) | |
| 93 | 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 | 26 | | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth | | |
| 27 | 27 | | `traces` | bool | `true` | Export distributed traces | | |
| 28 | 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 | 30 | | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) | | |
| 31 | 31 | ||
| 32 | 32 | ## How It Works | |
| @@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled: | |||
| 35 | 35 | ||
| 36 | 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 | 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 | 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 | 91 | ## Endpoint URL | |
| 43 | 92 | ||
| 44 | 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 | 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 | 115 | | Method | How | | |
| 67 | 116 | |--------|-----| | |
| @@ -98,9 +147,9 @@ Attributes: `domain`, `image` | |||
| 98 | 147 | |--------|------|------|-------------| | |
| 99 | 148 | | `gordon.container.restarts` | Counter | - | Container restart count | | |
| 100 | 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 attributes | |
| 152 | + | 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 | 154 | ### 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 | 5 | ## 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 | 9 | ```toml | |
| 10 | 10 | [entrypoints.edge] | |
| @@ -15,7 +15,7 @@ trusted_cidrs = [] | |||
| 15 | 15 | ||
| 16 | 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 | 20 | ## Smart TCP Dispatch Order | |
| 21 | 21 | ||
| @@ -73,7 +73,7 @@ protocol = "tcp" | |||
| 73 | 73 | ||
| 74 | 74 | Service references use: | |
| 75 | 75 | ||
| 76 | - | - `route:<domain>` for configured HTTP routes | |
| 76 | + | - `route:<domain>` for app HTTP hosts from ACTIVE state (merged with installation external routes) | |
| 77 | 77 | - `external_route:<domain>` for configured external HTTP routes | |
| 78 | 78 | - `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends | |
| 79 | 79 | - `service:<service>:<port-name>` for Gordon-managed standalone service backends | |
| @@ -96,7 +96,7 @@ sni = "raw.example.com" | |||
| 96 | 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 | 101 | ## Raw TCP Fallback | |
| 102 | 102 | ||
| @@ -176,6 +176,6 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma | |||
| 176 | 176 | ## Related | |
| 177 | 177 | ||
| 178 | 178 | - [Server Settings](./server.md) | |
| 179 | - | - [Routes](./routes.md) | |
| 179 | + | - [App Manifest](./apps.md) | |
| 180 | 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 | 1 | # 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 | - | ## Configuration | |
| 5 | + | ## Declarative app volumes | |
| 6 | + | ||
| 7 | + | Services declare persistent mounts in the app manifest: | |
| 6 | 8 | ||
| 7 | 9 | ```toml | |
| 8 | - | [volumes] | |
| 9 | - | auto_create = true | |
| 10 | - | prefix = "gordon" | |
| 11 | - | preserve = true | |
| 10 | + | [[services.web.volume]] | |
| 11 | + | name = "database-data" | |
| 12 | + | path = "/var/lib/postgresql/data" | |
| 12 | 13 | ``` | |
| 13 | 14 | ||
| 14 | - | ## Options | |
| 15 | - | ||
| 16 | - | | Option | Type | Default | Description | | |
| 17 | - | |--------|------|---------|-------------| | |
| 18 | - | | `auto_create` | bool | `true` | Automatically create volumes from Dockerfile VOLUME | | |
| 19 | - | | `prefix` | string | `"gordon"` | Prefix for volume names | | |
| 20 | - | | `preserve` | bool | `true` | Keep volumes when containers are removed | | |
| 15 | + | A volume name is unique within its service. Gordon creates an incarnation-owned runtime volume, records the app, app UUID, service, and logical volume ownership, and reuses it across replacement and restart. Manifests never carry host paths, and sharing one volume between services is not supported. When an app must read an operator-approved host location, use a named administrative bind instead. | |
| 21 | 16 | ||
| 22 | - | ## How It Works | |
| 17 | + | ## 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 | - | ```dockerfile | |
| 27 | - | FROM postgres:18 | |
| 28 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 21 | + | ```toml | |
| 22 | + | # gordon.toml | |
| 23 | + | [app_mounts.app-logs] | |
| 24 | + | source = "/srv/gordon/host-logs" # required; the only host path this mount may come from | |
| 25 | + | read_only = true | |
| 26 | + | allowed_apps = ["metrics-agent"] # required; exact, non-empty | |
| 27 | + | allowed_services = ["web"] # required; exact, non-empty | |
| 28 | + | # root = "/srv/gordon" # optional administrative boundary | |
| 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 | 31 | ```toml | |
| 52 | - | [volumes] | |
| 53 | - | preserve = true | |
| 32 | + | # app manifest | |
| 33 | + | [[services.web.bind]] | |
| 34 | + | name = "app-logs" # must match [app_mounts.<name>] | |
| 35 | + | path = "/var/lib/collector/host-logs" # container destination | |
| 36 | + | readonly = true | |
| 54 | 37 | ``` | |
| 55 | 38 | ||
| 56 | - | With `preserve = true`: | |
| 57 | - | - Volumes persist when containers are updated | |
| 58 | - | - Data survives container restarts | |
| 59 | - | - Volumes remain even if container is removed | |
| 39 | + | - `source` must be absolute, normalized, and resolve through symlinks to a regular file or directory that stays under `root`. When `root` is omitted, the source's parent directory is the boundary, so a symlinked source may resolve within it but never escape it. | |
| 40 | + | - `allowed_apps` and `allowed_services` are exact, non-empty allowlists; there is no wildcard, prefix, or empty-means-all form. | |
| 41 | + | - Read-only precedence: `read_only` on the policy or `readonly` on the bind forces the resolved mount read-only. A bind never weakens its policy. | |
| 42 | + | - Gordon re-resolves the policy immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed. `gordon serve` reload republishes validated policies atomically, and a failing edit keeps the previous policies live. | |
| 43 | + | - Gordon never creates, copies, deletes, chowns, backs up, or prunes the host path; ownership and permissions stay with the operator. | |
| 60 | 44 | ||
| 61 | - | ### Remove with Container | |
| 45 | + | A read-only log collector is the typical use. Its own state is a named volume; the host logs are exposed through a bind: | |
| 62 | 46 | ||
| 63 | 47 | ```toml | |
| 64 | - | [volumes] | |
| 65 | - | preserve = false | |
| 66 | - | ``` | |
| 67 | - | ||
| 68 | - | With `preserve = false`: | |
| 69 | - | - Volumes are removed when containers are removed | |
| 70 | - | - Useful for stateless containers | |
| 71 | - | - Frees up disk space automatically | |
| 48 | + | name = "metrics-agent" | |
| 72 | 49 | ||
| 73 | - | ## Examples | |
| 50 | + | [services.collector] | |
| 51 | + | image = "registry.example.com/metrics/collector:2.1.0" | |
| 74 | 52 | ||
| 75 | - | ### Database Container | |
| 53 | + | [[services.collector.volume]] | |
| 54 | + | name = "collector-state" | |
| 55 | + | path = "/var/lib/collector" | |
| 76 | 56 | ||
| 77 | - | ```dockerfile | |
| 78 | - | # my-postgres.Dockerfile | |
| 79 | - | FROM postgres:18 | |
| 80 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | - | ENV POSTGRES_DB=myapp | |
| 82 | - | ENV POSTGRES_USER=app | |
| 57 | + | [[services.collector.bind]] | |
| 58 | + | name = "app-logs" | |
| 59 | + | path = "/var/lib/collector/host-logs" | |
| 60 | + | readonly = true | |
| 83 | 61 | ``` | |
| 84 | 62 | ||
| 85 | - | Gordon automatically: | |
| 86 | - | - Creates `gordon-db-mydomain-com-var-lib-postgresql-data` volume | |
| 87 | - | - Mounts it to `/var/lib/postgresql/data` | |
| 88 | - | - Preserves data across postgres container updates | |
| 63 | + | Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind. | |
| 89 | 64 | ||
| 90 | - | ### Application with Uploads | |
| 65 | + | Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[services.<name>.volume]]` declaration maps it to a Gordon-owned volume; a `[[services.<name>.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path. | |
| 91 | 66 | ||
| 92 | - | ```dockerfile | |
| 93 | - | FROM node:18 | |
| 94 | - | WORKDIR /app | |
| 95 | - | VOLUME ["/app/uploads", "/app/data"] | |
| 96 | - | COPY . . | |
| 97 | - | CMD ["npm", "start"] | |
| 98 | - | ``` | |
| 67 | + | Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records. | |
| 99 | 68 | ||
| 100 | - | Creates two volumes: | |
| 101 | - | - `gordon-app-mydomain-com-app-uploads` | |
| 102 | - | - `gordon-app-mydomain-com-app-data` | |
| 69 | + | ## Retention | |
| 103 | 70 | ||
| 104 | - | ### Custom Prefix | |
| 71 | + | Ordinary app operations retain data: | |
| 105 | 72 | ||
| 106 | - | ```toml | |
| 107 | - | [volumes] | |
| 108 | - | prefix = "prod" | |
| 109 | - | ``` | |
| 73 | + | - deploy and restart reuse the service's volumes; | |
| 74 | + | - removing a service retains its volumes; | |
| 75 | + | - `gordon apps remove` retains volumes under the removed app's internal UUID; | |
| 76 | + | - a new app that reuses the public name does not adopt retained volumes; | |
| 77 | + | - administrative binds are unaffected by app operations: their host paths are never created, adopted, or retained by Gordon. | |
| 110 | 78 | ||
| 111 | - | Volume names become: | |
| 112 | - | - `prod-app-mydomain-com-data` | |
| 113 | - | - `prod-db-mydomain-com-var-lib-postgresql-data` | |
| 79 | + | Gordon does not automatically delete retained app volumes. Back up persistent data before any manual deletion, and use database-native backup and restore procedures for databases. | |
| 114 | 80 | ||
| 115 | - | ## Managing Volumes | |
| 81 | + | ## Manual migration | |
| 116 | 82 | ||
| 117 | - | ### List Volumes | |
| 83 | + | Gordon does not copy data between volumes. Stop every container that can write to the source, verify a backup, create an empty target volume, and perform the copy through the runtime so rootless UID/GID mappings are preserved. For Podman, a disposable helper container is the preferred generic method: | |
| 118 | 84 | ||
| 119 | 85 | ```bash | |
| 120 | - | docker volume ls | grep gordon | |
| 86 | + | podman run --rm --pull=never --network=none \ | |
| 87 | + | --volume "<source-volume>:/source:ro" \ | |
| 88 | + | --volume "<target-volume>:/target" \ | |
| 89 | + | docker.io/library/alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \ | |
| 90 | + | sh -c 'cp -a /source/. /target/' | |
| 121 | 91 | ``` | |
| 122 | 92 | ||
| 123 | - | ### Inspect Volume | |
| 93 | + | Replace the literal `<...>` placeholders before running; they are quoted above so a shell cannot mistake them for redirection. The approved helper image is digest-pinned, must already be available locally, and runs without network access. If direct access to storage paths is unavoidable with rootless Podman, enter its user namespace: | |
| 124 | 94 | ||
| 125 | 95 | ```bash | |
| 126 | - | docker volume inspect gordon-app-mydomain-com-data | |
| 96 | + | podman unshare cp -a "<source-path>"/. "<target-path>"/ | |
| 127 | 97 | ``` | |
| 128 | 98 | ||
| 129 | - | ### Backup Volume | |
| 99 | + | Do not use a plain root `cp` as the default procedure: host ownership IDs may not match the container's user namespace. Applications that rely on ACLs, extended attributes, sparse files, or database consistency need an application-specific export/restore or copy tool. Validate ownership and application behavior against the target, and retain the source volume until the migration is accepted. | |
| 130 | 100 | ||
| 131 | - | ```bash | |
| 132 | - | docker run --rm \ | |
| 133 | - | -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \ | |
| 134 | - | -v $(pwd):/backup \ | |
| 135 | - | alpine tar -czf /backup/db-backup.tar.gz -C /data . | |
| 136 | - | ``` | |
| 101 | + | ## Pruning | |
| 137 | 102 | ||
| 138 | - | ### Restore Volume | |
| 103 | + | Use Gordon's ownership-aware command: | |
| 139 | 104 | ||
| 140 | 105 | ```bash | |
| 141 | - | docker run --rm \ | |
| 142 | - | -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \ | |
| 143 | - | -v $(pwd):/backup \ | |
| 144 | - | alpine tar -xzf /backup/db-backup.tar.gz -C /data | |
| 106 | + | gordon volumes prune --dry-run | |
| 107 | + | gordon volumes prune | |
| 145 | 108 | ``` | |
| 146 | 109 | ||
| 147 | - | ## Volume Cleanup | |
| 110 | + | 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 | - | ```bash | |
| 152 | - | # List all gordon volumes | |
| 153 | - | docker volume ls -f name=gordon | |
| 116 | + | Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. Administrative binds are out of scope entirely: they are operator-owned host paths, no ownership record exists for them, and they are never candidates. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions. | |
| 154 | 117 | ||
| 155 | - | # Remove specific volume (warning: deletes data!) | |
| 156 | - | docker volume rm gordon-old-app-data | |
| 118 | + | > **Warning:** Do not use `docker volume prune`, `podman volume prune`, or equivalent runtime cleanup for Gordon data. Those commands bypass Gordon's ownership and retention checks and can delete an unmounted retained volume. | |
| 157 | 119 | ||
| 158 | - | # Prune unused volumes (be careful!) | |
| 159 | - | docker volume prune | |
| 160 | - | ``` | |
| 120 | + | See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output. | |
| 161 | 121 | ||
| 162 | 122 | ## Related | |
| 163 | 123 | ||
| 164 | - | - [Attachments](./attachments.md) | |
| 124 | + | - [App Manifest](./apps.md) | |
| 125 | + | - [Volumes CLI](../cli/volumes.md) | |
| 165 | 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 | 5 | ## Requirements | |
| 6 | 6 | ||
| 7 | 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 | 9 | - Gordon binary (optional but recommended) | |
| 10 | 10 | ||
| 11 | - | ## Recommended: gordon push | |
| 11 | + | ## Recommended: gordon images push | |
| 12 | 12 | ||
| 13 | 13 | ### 1. Generate Deployment Token | |
| 14 | 14 | ||
| @@ -17,7 +17,7 @@ On your Gordon server: | |||
| 17 | 17 | ```bash | |
| 18 | 18 | gordon auth token generate \ | |
| 19 | 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 | 21 | --expiry 0 | |
| 22 | 22 | ``` | |
| 23 | 23 | ||
| @@ -40,9 +40,9 @@ chmod +x /usr/local/bin/gordon | |||
| 40 | 40 | ||
| 41 | 41 | # 2. Build, push, and deploy | |
| 42 | 42 | export GORDON_TOKEN="$GORDON_TOKEN" | |
| 43 | - | gordon push --build \ | |
| 43 | + | gordon images push --build \ | |
| 44 | 44 | --remote "$GORDON_REMOTE" \ | |
| 45 | - | --no-confirm | |
| 45 | + | ||
| 46 | 46 | ``` | |
| 47 | 47 | ||
| 48 | 48 | Gordon auto-detects the version from CI environment variables: | |
| @@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables: | |||
| 52 | 52 | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 53 | 53 | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 54 | 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 | 57 | ## Alternative: docker push | |
| 58 | 58 | ||
| @@ -69,7 +69,7 @@ docker push gordon.example.com/myapp:v1.0.0 | |||
| 69 | 69 | ``` | |
| 70 | 70 | ||
| 71 | 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 | 74 | ## CI System Examples | |
| 75 | 75 | ||
| @@ -89,7 +89,7 @@ pipeline { | |||
| 89 | 89 | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |
| 90 | 90 | -o /usr/local/bin/gordon | |
| 91 | 91 | chmod +x /usr/local/bin/gordon | |
| 92 | - | gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |
| 92 | + | gordon images push --build --remote "$GORDON_REMOTE" | |
| 93 | 93 | ''' | |
| 94 | 94 | } | |
| 95 | 95 | } | |
| @@ -119,9 +119,8 @@ jobs: | |||
| 119 | 119 | - run: | |
| 120 | 120 | name: Deploy | |
| 121 | 121 | command: | | |
| 122 | - | gordon push --build \ | |
| 123 | - | --remote "$GORDON_REMOTE" \ | |
| 124 | - | --no-confirm | |
| 122 | + | gordon images push --build \ | |
| 123 | + | --remote "$GORDON_REMOTE" | |
| 125 | 124 | ||
| 126 | 125 | workflows: | |
| 127 | 126 | deploy: | |
| @@ -153,7 +152,7 @@ steps: | |||
| 153 | 152 | - apk add --no-cache curl | |
| 154 | 153 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 155 | 154 | - chmod +x /usr/local/bin/gordon | |
| 156 | - | - gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |
| 155 | + | - gordon images push --build --remote "$GORDON_REMOTE" | |
| 157 | 156 | ||
| 158 | 157 | trigger: | |
| 159 | 158 | event: | |
| @@ -162,11 +161,13 @@ trigger: | |||
| 162 | 161 | ||
| 163 | 162 | ## 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 | 166 | | Workflow | Required Scopes | | |
| 166 | 167 | |----------|----------------| | |
| 167 | - | | Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` | | |
| 168 | - | | Push only (no deploy) | `push,pull,admin:routes:read` | | |
| 169 | - | | docker push (auto-deploy) | `push,pull` | | |
| 168 | + | | Build + push, then apply + deploy | `push,pull,admin:apps:read,admin:apps:write` | | |
| 169 | + | | Push only (no deploy) | `push,pull` | | |
| 170 | + | | docker push, then apply + deploy | `push,pull` + `admin:apps:read,admin:apps:write` for the CLI step | | |
| 170 | 171 | ||
| 171 | 172 | ## Troubleshooting | |
| 172 | 173 | ||
| @@ -174,21 +175,20 @@ trigger: | |||
| 174 | 175 | ||
| 175 | 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 | 182 | ```bash | |
| 182 | - | gordon routes add myapp.example.com myapp | |
| 183 | - | # or for first deploy: | |
| 184 | - | gordon bootstrap myapp.example.com myapp | |
| 183 | + | gordon apps apply --file myapp.toml --remote "$GORDON_REMOTE" | |
| 184 | + | gordon apps deploy myapp --remote "$GORDON_REMOTE" | |
| 185 | 185 | ``` | |
| 186 | 186 | ||
| 187 | 187 | ### Version shows "latest" | |
| 188 | 188 | ||
| 189 | 189 | Gordon could not detect a version tag. Either: | |
| 190 | 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 | 193 | ### 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 | 200 | - [GitLab CI](./gitlab-ci.md) | |
| 201 | 201 | - [Deployment Overview](./index.md) | |
| 202 | 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 | 1 | # 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 | 5 | ## Prerequisites | |
| 6 | 6 | ||
| 7 | - | 1. Gordon server running with registry authentication enabled | |
| 8 | - | 2. Deployment token generated with the required scopes | |
| 7 | + | 1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port` | |
| 8 | + | 2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps | |
| 9 | 9 | 3. GitHub repository secrets configured | |
| 10 | 10 | ||
| 11 | - | ## Recommended: gordon push | |
| 11 | + | ## 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 | 15 | ### 1. Generate Token | |
| 16 | 16 | ||
| @@ -19,7 +19,7 @@ On your Gordon server: | |||
| 19 | 19 | ```bash | |
| 20 | 20 | gordon auth token generate \ | |
| 21 | 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 | 23 | --expiry 0 | |
| 24 | 24 | ``` | |
| 25 | 25 | ||
| @@ -71,9 +71,8 @@ jobs: | |||
| 71 | 71 | env: | |
| 72 | 72 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 73 | 73 | run: | | |
| 74 | - | gordon push --build \ | |
| 75 | - | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 76 | - | --no-confirm | |
| 74 | + | gordon images push --build \ | |
| 75 | + | --remote ${{ secrets.GORDON_REMOTE }} | |
| 77 | 76 | ``` | |
| 78 | 77 | ||
| 79 | 78 | #### Continuous Deploy on Main | |
| @@ -103,10 +102,9 @@ jobs: | |||
| 103 | 102 | env: | |
| 104 | 103 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 105 | 104 | run: | | |
| 106 | - | gordon push --build \ | |
| 105 | + | gordon images push --build \ | |
| 107 | 106 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 108 | - | --tag latest \ | |
| 109 | - | --no-confirm | |
| 107 | + | --tag latest | |
| 110 | 108 | ``` | |
| 111 | 109 | ||
| 112 | 110 | #### Manual Dispatch | |
| @@ -141,7 +139,7 @@ jobs: | |||
| 141 | 139 | GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }} | |
| 142 | 140 | DEPLOY_TAG: ${{ inputs.tag }} | |
| 143 | 141 | run: | | |
| 144 | - | args=(push --build --remote "$GORDON_REMOTE" --no-confirm) | |
| 142 | + | args=(push --build --remote "$GORDON_REMOTE") | |
| 145 | 143 | if [[ -n "$DEPLOY_TAG" ]]; then | |
| 146 | 144 | [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | |
| 147 | 145 | args+=(--tag "$DEPLOY_TAG") | |
| @@ -151,7 +149,7 @@ jobs: | |||
| 151 | 149 | ||
| 152 | 150 | #### 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 | 154 | ```yaml | |
| 157 | 155 | name: Deploy Services | |
| @@ -173,21 +171,19 @@ jobs: | |||
| 173 | 171 | env: | |
| 174 | 172 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 175 | 173 | run: | | |
| 176 | - | gordon push --build \ | |
| 174 | + | gordon images push --build \ | |
| 177 | 175 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 178 | 176 | --file ./services/api/Dockerfile \ | |
| 179 | - | myapp-api \ | |
| 180 | - | --no-confirm | |
| 177 | + | myapp-api | |
| 181 | 178 | ||
| 182 | 179 | - name: Deploy Web | |
| 183 | 180 | env: | |
| 184 | 181 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 185 | 182 | run: | | |
| 186 | - | gordon push --build \ | |
| 183 | + | gordon images push --build \ | |
| 187 | 184 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 188 | 185 | --file ./services/web/Dockerfile \ | |
| 189 | - | myapp-web \ | |
| 190 | - | --no-confirm | |
| 186 | + | myapp-web | |
| 191 | 187 | ``` | |
| 192 | 188 | ||
| 193 | 189 | #### With Build Args | |
| @@ -199,12 +195,12 @@ Pass build arguments to the Docker build: | |||
| 199 | 195 | env: | |
| 200 | 196 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 201 | 197 | run: | | |
| 202 | - | gordon push --build \ | |
| 198 | + | gordon images push --build \ | |
| 203 | 199 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 204 | 200 | --build-arg NODE_ENV=production \ | |
| 205 | 201 | --build-arg API_URL=https://api.example.com \ | |
| 206 | 202 | --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \ | |
| 207 | - | --no-confirm | |
| 203 | + | ||
| 208 | 204 | ``` | |
| 209 | 205 | ||
| 210 | 206 | ### setup-gordon Action Reference | |
| @@ -220,7 +216,7 @@ Pass build arguments to the Docker build: | |||
| 220 | 216 | ||
| 221 | 217 | ## 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 | 221 | ### Setup | |
| 226 | 222 | ||
| @@ -482,5 +478,5 @@ Error: unauthorized: authentication required | |||
| 482 | 478 | - [Generic CI](./generic-ci.md) | |
| 483 | 479 | - [Deployment Overview](./index.md) | |
| 484 | 480 | - [Authentication](../config/auth.md) | |
| 485 | - | - [Push Command](../cli/push.md) | |
| 481 | + | - [Images Commands](../cli/images.md#gordon-images-push) | |
| 486 | 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 | 5 | ## Prerequisites | |
| 6 | 6 | ||
| 7 | - | 1. Gordon server running with registry authentication enabled | |
| 8 | - | 2. Generated deployment token | |
| 7 | + | 1. Gordon server running with registry authentication enabled; CI reaches the public Gordon HTTPS domain, not the loopback `server.registry_port` | |
| 8 | + | 2. A least-privilege token: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the pipeline applies or deploys apps | |
| 9 | 9 | 3. GitLab CI/CD variables configured | |
| 10 | 10 | ||
| 11 | 11 | ## Quick Setup | |
| @@ -17,7 +17,7 @@ On your Gordon server: | |||
| 17 | 17 | ```bash | |
| 18 | 18 | gordon auth token generate \ | |
| 19 | 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 | 21 | --expiry 0 | |
| 22 | 22 | ``` | |
| 23 | 23 | ||
| @@ -45,10 +45,10 @@ deploy: | |||
| 45 | 45 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 46 | 46 | - chmod +x /usr/local/bin/gordon | |
| 47 | 47 | script: | |
| 48 | - | - gordon push --build | |
| 48 | + | - gordon images push --build | |
| 49 | 49 | --remote "$GORDON_REMOTE" | |
| 50 | 50 | --token "$GORDON_TOKEN" | |
| 51 | - | --no-confirm | |
| 51 | + | ||
| 52 | 52 | only: | |
| 53 | 53 | - tags | |
| 54 | 54 | ``` | |
| @@ -73,10 +73,10 @@ deploy: | |||
| 73 | 73 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 74 | 74 | - chmod +x /usr/local/bin/gordon | |
| 75 | 75 | script: | |
| 76 | - | - gordon push --build | |
| 76 | + | - gordon images push --build | |
| 77 | 77 | --remote "$GORDON_REMOTE" | |
| 78 | 78 | --token "$GORDON_TOKEN" | |
| 79 | - | --no-confirm | |
| 79 | + | ||
| 80 | 80 | rules: | |
| 81 | 81 | - if: $CI_COMMIT_TAG | |
| 82 | 82 | ``` | |
| @@ -96,10 +96,10 @@ deploy: | |||
| 96 | 96 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 97 | 97 | - chmod +x /usr/local/bin/gordon | |
| 98 | 98 | script: | |
| 99 | - | - gordon push --build | |
| 99 | + | - gordon images push --build | |
| 100 | 100 | --remote "$GORDON_REMOTE" | |
| 101 | 101 | --token "$GORDON_TOKEN" | |
| 102 | - | --no-confirm | |
| 102 | + | ||
| 103 | 103 | rules: | |
| 104 | 104 | - if: $CI_COMMIT_BRANCH == "main" | |
| 105 | 105 | ``` | |
| @@ -117,10 +117,10 @@ deploy: | |||
| 117 | 117 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 118 | 118 | - chmod +x /usr/local/bin/gordon | |
| 119 | 119 | script: | |
| 120 | - | - gordon push --build | |
| 120 | + | - gordon images push --build | |
| 121 | 121 | --remote "$GORDON_REMOTE" | |
| 122 | 122 | --token "$GORDON_TOKEN" | |
| 123 | - | --no-confirm | |
| 123 | + | ||
| 124 | 124 | when: manual | |
| 125 | 125 | ``` | |
| 126 | 126 | ||
| @@ -137,12 +137,12 @@ deploy: | |||
| 137 | 137 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 138 | 138 | - chmod +x /usr/local/bin/gordon | |
| 139 | 139 | script: | |
| 140 | - | - gordon push --build | |
| 140 | + | - gordon images push --build | |
| 141 | 141 | --remote "$GORDON_REMOTE" | |
| 142 | 142 | --token "$GORDON_TOKEN" | |
| 143 | 143 | --build-arg NODE_ENV=production | |
| 144 | 144 | --build-arg API_URL=https://api.example.com | |
| 145 | - | --no-confirm | |
| 145 | + | ||
| 146 | 146 | rules: | |
| 147 | 147 | - if: $CI_COMMIT_TAG | |
| 148 | 148 | ``` | |
| @@ -166,7 +166,7 @@ deploy: | |||
| 166 | 166 | ``` | |
| 167 | 167 | ||
| 168 | 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 | 171 | ## Version Detection | |
| 172 | 172 | ||
| @@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th | |||
| 208 | 208 | - [Generic CI](./generic-ci.md) | |
| 209 | 209 | - [Deployment Overview](./index.md) | |
| 210 | 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 | 1 | # 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 push | |
| 5 | + | ## 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 | 9 | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | |
| 11 | 10 | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) | |
| 12 | 11 | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | |
| 13 | 12 | ||
| 14 | 13 | ```bash | |
| 15 | - | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 14 | + | gordon images push --build --remote https://gordon.example.com | |
| 15 | + | ``` | |
| 16 | + | ||
| 17 | + | Then activate (from CI with the Gordon binary, or from your machine): | |
| 18 | + | ||
| 19 | + | ```bash | |
| 20 | + | gordon apps apply --file blog.toml --remote https://gordon.example.com | |
| 21 | + | gordon apps deploy blog --remote https://gordon.example.com | |
| 16 | 22 | ``` | |
| 17 | 23 | ||
| 18 | 24 | ## All Deployment Methods | |
| 19 | 25 | ||
| 20 | 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) | | |
| 23 | - | | `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) | | |
| 24 | - | | Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) | | |
| 28 | + | | `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 29 | + | | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 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 | 35 | - Single token handles everything: admin API access + registry auth via automatic token exchange | |
| 31 | 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 | 37 | - Requires the Gordon binary on the CI runner | |
| 34 | 38 | ||
| 35 | 39 | ```bash | |
| 36 | - | # Build, push, and deploy | |
| 37 | - | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 40 | + | # Build and push (OCI transfer only) | |
| 41 | + | gordon images push --build --remote https://gordon.example.com | |
| 38 | 42 | ||
| 39 | - | # Push only, no deploy | |
| 40 | - | gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm | |
| 43 | + | # Apply the manifest that references the pushed tag, then deploy | |
| 44 | + | gordon apps apply --file blog.toml --remote https://gordon.example.com | |
| 45 | + | gordon apps deploy blog --remote https://gordon.example.com | |
| 41 | 46 | ``` | |
| 42 | 47 | ||
| 43 | - | ### Method 2: docker push | |
| 48 | + | ### 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 | 52 | - Use `docker login`, `docker build`, and `docker push` as usual | |
| 48 | - | - Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed) | |
| 49 | - | - No Gordon binary needed on the CI runner | |
| 53 | + | - Pushing only stores the image; deploy explicitly with the Gordon CLI afterwards | |
| 50 | 54 | - Registry endpoint is `gordon.example.com` (not a separate registry host) | |
| 51 | 55 | ||
| 52 | 56 | ```bash | |
| 53 | 57 | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com | |
| 54 | 58 | docker build -t gordon.example.com/myapp:v1.2.0 . | |
| 55 | 59 | 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 | 63 | ## 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 | 67 | ### Network Topologies | |
| 78 | 68 | ||
| @@ -87,34 +77,27 @@ Gordon's registry is served through the main gordon domain over HTTPS on port 44 | |||
| 87 | 77 | See the examples below for the right scopes for each workflow. | |
| 88 | 78 | ||
| 89 | 79 | ```bash | |
| 90 | - | # Minimum scope for gordon push — route lookup + registry push | |
| 91 | - | # Server auto-deploys when it receives the image | |
| 80 | + | # Push only — registry scopes | |
| 92 | 81 | gordon auth token generate \ | |
| 93 | 82 | --subject ci-bot \ | |
| 94 | - | --scopes "push,pull,admin:routes:read" \ | |
| 83 | + | --scopes "push,pull" \ | |
| 95 | 84 | --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 | 87 | gordon auth token generate \ | |
| 99 | 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 | 90 | --expiry 90d | |
| 102 | 91 | ||
| 103 | - | # Scoped to a specific repository | |
| 92 | + | # Repository-scoped registry token for push only | |
| 104 | 93 | gordon auth token generate \ | |
| 105 | 94 | --subject ci-bot \ | |
| 106 | 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 | 96 | --scopes "push,pull" \ | |
| 114 | 97 | --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 | 102 | ## Version Strategies | |
| 120 | 103 | ||
| @@ -127,26 +110,23 @@ docker tag myapp gordon.example.com/myapp:latest | |||
| 127 | 110 | docker push gordon.example.com/myapp:latest | |
| 128 | 111 | ``` | |
| 129 | 112 | ||
| 113 | + | Reference it from the app manifest: | |
| 114 | + | ||
| 130 | 115 | ```toml | |
| 131 | - | [routes] | |
| 132 | - | "app.example.com" = "myapp:latest" | |
| 116 | + | [services.web] | |
| 117 | + | image = "gordon.example.com/myapp:latest" | |
| 133 | 118 | ``` | |
| 134 | 119 | ||
| 135 | 120 | ### 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 | 124 | ```bash | |
| 140 | 125 | docker tag myapp gordon.example.com/myapp:v2.1.0 | |
| 141 | 126 | docker push gordon.example.com/myapp:v2.1.0 | |
| 142 | 127 | ``` | |
| 143 | 128 | ||
| 144 | - | ```toml | |
| 145 | - | [routes] | |
| 146 | - | "app.example.com" = "myapp:v2.1.0" | |
| 147 | - | ``` | |
| 148 | - | ||
| 149 | - | To deploy a new version, update the tag in `gordon.toml` and push the new image. | |
| 129 | + | To deploy a new version, update the tag in the app file, apply, and deploy. | |
| 150 | 130 | ||
| 151 | 131 | ### Git SHA Tags | |
| 152 | 132 | ||
| @@ -158,30 +138,22 @@ docker tag myapp gordon.example.com/myapp:$VERSION | |||
| 158 | 138 | docker push gordon.example.com/myapp:$VERSION | |
| 159 | 139 | ``` | |
| 160 | 140 | ||
| 161 | - | ## Zero-Downtime Deployment | |
| 162 | - | ||
| 163 | - | Gordon performs zero-downtime deployments by default: | |
| 141 | + | ## Updates | |
| 164 | 142 | ||
| 165 | - | 1. **New container starts** while the old one is still running | |
| 166 | - | 2. **Health check** waits for the new container to become ready | |
| 167 | - | 3. **Traffic switches** to the new container | |
| 168 | - | 4. **Old container stops** after traffic moves | |
| 143 | + | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time. | |
| 169 | 144 | ||
| 170 | 145 | ``` | |
| 171 | - | Timeline ─────────────────────────────────────────> | |
| 172 | - | ||
| 173 | - | Old Container: [═══════════════════] | |
| 174 | - | ↓ stop | |
| 175 | - | New Container: [═════════════════════════> | |
| 176 | - | ↑ start ↑ traffic routed | |
| 146 | + | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | |
| 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 | 153 | ## Related | |
| 180 | 154 | ||
| 181 | 155 | - [GitHub Actions](./github-actions.md) | |
| 182 | 156 | - [GitLab CI](./gitlab-ci.md) | |
| 183 | 157 | - [Generic CI](./generic-ci.md) | |
| 184 | - | - [Rollback](./rollback.md) | |
| 185 | - | - [Routes Configuration](../config/routes.md) | |
| 158 | + | - [Apps CLI](../cli/apps.md) | |
| 186 | 159 | - [Authentication](../config/auth.md) | |
| 187 | - | - [Auto-Route](../config/auto-route.md) | |
M docs/deployment/rollback.md +28 -175
| @@ -1,62 +1,28 @@ | |||
| 1 | 1 | # 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 | 9 | ```bash | |
| 10 | - | gordon pin app.example.com | |
| 11 | - | gordon pin app.example.com --tag v2.30.1 | |
| 12 | - | gordon pin list app.example.com | |
| 10 | + | # 1. Edit blog.toml: image = "gordon.mydomain.com/myapp:v2.0.0" | |
| 11 | + | gordon apps apply --file blog.toml --remote prod | |
| 12 | + | gordon apps deploy blog --remote prod | |
| 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 Strategies | |
| 17 | + | ## Re-push the Previous Image as latest | |
| 18 | 18 | ||
| 19 | - | ### 1. Config-Based Rollback | |
| 20 | - | ||
| 21 | - | Update the route to point to the previous version: | |
| 22 | - | ||
| 23 | - | ```toml | |
| 24 | - | # Current (broken) | |
| 25 | - | [routes] | |
| 26 | - | "app.mydomain.com" = "myapp:v2.1.0" | |
| 27 | - | ||
| 28 | - | # Rollback to previous | |
| 29 | - | [routes] | |
| 30 | - | "app.mydomain.com" = "myapp:v2.0.0" | |
| 31 | - | ``` | |
| 32 | - | ||
| 33 | - | Then reload: | |
| 34 | - | ||
| 35 | - | ```bash | |
| 36 | - | gordon reload | |
| 37 | - | ``` | |
| 38 | - | ||
| 39 | - | ### 2. Push Previous Version | |
| 40 | - | ||
| 41 | - | Re-push the previous image with the `latest` tag: | |
| 19 | + | When the app file tracks `latest`, re-tag and re-push, then deploy (deploy re-resolves mutable tags): | |
| 42 | 20 | ||
| 43 | 21 | ```bash | |
| 44 | - | docker pull registry.mydomain.com/myapp:v2.0.0 | |
| 45 | - | docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest | |
| 46 | - | docker push registry.mydomain.com/myapp:latest | |
| 47 | - | ``` | |
| 48 | - | ||
| 49 | - | ### 3. Manifest-Based Rollback | |
| 50 | - | ||
| 51 | - | Use OCI manifest annotations for version control: | |
| 52 | - | ||
| 53 | - | ```bash | |
| 54 | - | # Rollback to v2.0.0 | |
| 55 | - | export VERSION=v2.0.0 | |
| 56 | - | podman manifest create myapp:latest --amend | |
| 57 | - | podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION | |
| 58 | - | podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION | |
| 59 | - | podman manifest push myapp:latest registry.mydomain.com/myapp:latest | |
| 22 | + | docker pull gordon.mydomain.com/myapp:v2.0.0 | |
| 23 | + | docker tag gordon.mydomain.com/myapp:v2.0.0 gordon.mydomain.com/myapp:latest | |
| 24 | + | docker push gordon.mydomain.com/myapp:latest | |
| 25 | + | gordon apps deploy blog --remote prod | |
| 60 | 26 | ``` | |
| 61 | 27 | ||
| 62 | 28 | ## Version Management | |
| @@ -67,169 +33,56 @@ Always push versioned tags alongside `latest`: | |||
| 67 | 33 | ||
| 68 | 34 | ```bash | |
| 69 | 35 | VERSION=$(git describe --tags) | |
| 70 | - | docker tag myapp registry.mydomain.com/myapp:$VERSION | |
| 71 | - | docker tag myapp registry.mydomain.com/myapp:latest | |
| 72 | - | docker push registry.mydomain.com/myapp:$VERSION | |
| 73 | - | docker push registry.mydomain.com/myapp:latest | |
| 36 | + | docker tag myapp gordon.mydomain.com/myapp:$VERSION | |
| 37 | + | docker tag myapp gordon.mydomain.com/myapp:latest | |
| 38 | + | docker push gordon.mydomain.com/myapp:$VERSION | |
| 39 | + | docker push gordon.mydomain.com/myapp:latest | |
| 74 | 40 | ``` | |
| 75 | 41 | ||
| 76 | 42 | ### 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 | 47 | v2.1.0 ← Current (broken) | |
| 82 | - | v2.0.0 ← Rollback target | |
| 48 | + | v2.0.0 ← Recovery target | |
| 83 | 49 | v1.9.0 ← Older stable | |
| 84 | 50 | ``` | |
| 85 | 51 | ||
| 86 | 52 | ### Git SHA Tags | |
| 87 | 53 | ||
| 88 | - | Tag with commit SHA for precise rollbacks: | |
| 54 | + | Tag with commit SHA for precise recovery: | |
| 89 | 55 | ||
| 90 | 56 | ```bash | |
| 91 | 57 | # Deploy | |
| 92 | 58 | SHA=$(git rev-parse --short HEAD) | |
| 93 | - | docker push registry.mydomain.com/myapp:$SHA | |
| 94 | - | ||
| 95 | - | # Rollback to specific commit | |
| 96 | - | docker pull registry.mydomain.com/myapp:abc1234 | |
| 97 | - | docker tag registry.mydomain.com/myapp:abc1234 registry.mydomain.com/myapp:latest | |
| 98 | - | docker push registry.mydomain.com/myapp:latest | |
| 99 | - | ``` | |
| 100 | - | ||
| 101 | - | ## Rollback Workflow | |
| 102 | - | ||
| 103 | - | ### Quick Rollback | |
| 104 | - | ||
| 105 | - | ```bash | |
| 106 | - | # 1. Identify last working version | |
| 107 | - | docker image ls registry.mydomain.com/myapp | |
| 108 | - | ||
| 109 | - | # 2. Tag as latest | |
| 110 | - | docker tag registry.mydomain.com/myapp:v2.0.0 registry.mydomain.com/myapp:latest | |
| 111 | - | ||
| 112 | - | # 3. Push | |
| 113 | - | docker push registry.mydomain.com/myapp:latest | |
| 114 | - | ``` | |
| 115 | - | ||
| 116 | - | ### Config Rollback | |
| 117 | - | ||
| 118 | - | ```bash | |
| 119 | - | # 1. Edit config | |
| 120 | - | vim ~/.config/gordon/gordon.toml | |
| 121 | - | ||
| 122 | - | # 2. Change version | |
| 123 | - | # "app.mydomain.com" = "myapp:v2.0.0" | |
| 124 | - | ||
| 125 | - | # 3. Reload | |
| 126 | - | gordon reload | |
| 127 | - | ``` | |
| 128 | - | ||
| 129 | - | ## Automated Rollback | |
| 130 | - | ||
| 131 | - | ### GitHub Actions | |
| 132 | - | ||
| 133 | - | Add rollback capability to your workflow: | |
| 134 | - | ||
| 135 | - | ```yaml | |
| 136 | - | name: Rollback | |
| 137 | - | ||
| 138 | - | on: | |
| 139 | - | workflow_dispatch: | |
| 140 | - | inputs: | |
| 141 | - | version: | |
| 142 | - | description: 'Version to rollback to (e.g., v2.0.0)' | |
| 143 | - | required: true | |
| 144 | - | ||
| 145 | - | jobs: | |
| 146 | - | rollback: | |
| 147 | - | runs-on: ubuntu-latest | |
| 148 | - | steps: | |
| 149 | - | - name: Login to Registry | |
| 150 | - | env: | |
| 151 | - | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 152 | - | GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }} | |
| 153 | - | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |
| 154 | - | run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY" | |
| 155 | - | ||
| 156 | - | - name: Rollback | |
| 157 | - | env: | |
| 158 | - | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |
| 159 | - | VERSION: ${{ inputs.version }} | |
| 160 | - | run: | | |
| 161 | - | [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | |
| 162 | - | REPOSITORY="$GORDON_REGISTRY/myapp" | |
| 163 | - | SOURCE_IMAGE="$REPOSITORY:$VERSION" | |
| 164 | - | LATEST_IMAGE="$REPOSITORY:latest" | |
| 165 | - | SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 166 | - | IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST" | |
| 167 | - | docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE" | |
| 168 | - | LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 169 | - | [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || { | |
| 170 | - | echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2 | |
| 171 | - | exit 1 | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | - name: Summary | |
| 175 | - | env: | |
| 176 | - | VERSION: ${{ inputs.version }} | |
| 177 | - | run: | | |
| 178 | - | echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY" | |
| 179 | - | printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" | |
| 180 | - | ``` | |
| 181 | - | ||
| 182 | - | ### Rollback Script | |
| 183 | - | ||
| 184 | - | Create a local rollback script: | |
| 185 | - | ||
| 186 | - | ```bash | |
| 187 | - | #!/bin/bash | |
| 188 | - | # rollback.sh | |
| 189 | - | ||
| 190 | - | REGISTRY="registry.mydomain.com" | |
| 191 | - | IMAGE="myapp" | |
| 192 | - | VERSION=$1 | |
| 193 | - | ||
| 194 | - | if [ -z "$VERSION" ]; then | |
| 195 | - | echo "Usage: ./rollback.sh <version>" | |
| 196 | - | echo "Available versions:" | |
| 197 | - | docker image ls "$REGISTRY/$IMAGE" --format "{{.Tag}}" | |
| 198 | - | exit 1 | |
| 199 | - | fi | |
| 200 | - | ||
| 201 | - | echo "Rolling back to $IMAGE:$VERSION..." | |
| 202 | - | docker pull "$REGISTRY/$IMAGE:$VERSION" | |
| 203 | - | docker tag "$REGISTRY/$IMAGE:$VERSION" "$REGISTRY/$IMAGE:latest" | |
| 204 | - | docker push "$REGISTRY/$IMAGE:latest" | |
| 205 | - | echo "Rollback complete!" | |
| 59 | + | docker push gordon.mydomain.com/myapp:$SHA | |
| 206 | 60 | ``` | |
| 207 | 61 | ||
| 208 | - | ## Verifying Rollback | |
| 62 | + | ## Verifying Recovery | |
| 209 | 63 | ||
| 210 | - | After rollback: | |
| 64 | + | After re-deploying the previous version: | |
| 211 | 65 | ||
| 212 | 66 | ```bash | |
| 213 | - | # Check container is running correct version | |
| 214 | - | docker inspect gordon-app-mydomain-com | grep Image | |
| 67 | + | # Check the app's effective vs observed state | |
| 68 | + | gordon apps status blog --remote prod | |
| 215 | 69 | ||
| 216 | 70 | # Check application responds | |
| 217 | 71 | curl -I https://app.mydomain.com | |
| 218 | 72 | ||
| 219 | 73 | # Check logs | |
| 220 | - | gordon logs -f | |
| 74 | + | gordon apps logs blog --remote prod | |
| 221 | 75 | ``` | |
| 222 | 76 | ||
| 223 | 77 | ## Best Practices | |
| 224 | 78 | ||
| 225 | 79 | 1. **Always tag versions** - Don't rely solely on `latest` | |
| 226 | - | 2. **Keep N previous versions** - Maintain rollback options | |
| 80 | + | 2. **Keep N previous versions** - Maintain recovery options | |
| 227 | 81 | 3. **Test before deploy** - Reduce need for rollbacks | |
| 228 | 82 | 4. **Document known-good versions** - Track stable releases | |
| 229 | - | 5. **Automate rollback** - Reduce time to recovery | |
| 230 | 83 | ||
| 231 | 84 | ## Related | |
| 232 | 85 | ||
| 233 | 86 | - [Deployment Overview](./index.md) | |
| 234 | 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 | 12 | ## 1. Install Gordon | |
| 13 | 13 | ||
| 14 | 14 | ```bash | |
| 15 | - | curl -fsSL https://gordon.bnema.dev/install | bash | |
| 15 | + | curl -fsSL https://bnema.dev/gordon/install | bash | |
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 18 | 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 | 79 | [entrypoints.edge] | |
| 80 | 80 | address = ":443" # Public smart TCP edge (choose your bind/mapping) | |
| 81 | 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 | 86 | ## 5. Set Up DNS (Including Wildcard) | |
| 88 | 87 | ||
| 89 | 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 | 95 | | A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes | | |
| 97 | 96 | ||
| 98 | 97 | Why wildcard (`*`)? | |
| 99 | - | - It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc. | |
| 100 | - | - You can add new domains in `[routes]` without creating DNS records one by one. | |
| 98 | + | - It automatically covers app hosts like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc. | |
| 99 | + | - You can add new app HTTP hosts without creating DNS records one by one. | |
| 101 | 100 | ||
| 102 | 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 | 129 | ## 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 | 133 | ```bash | |
| 135 | - | gordon auth token generate --subject deploy --scopes push,pull --expiry 0 | |
| 134 | + | gordon auth token generate --subject deploy --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 90d | |
| 136 | 135 | ``` | |
| 137 | 136 | ||
| 138 | 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 | 147 | gordon remotes add prod https://gordon.mydomain.com --token <your-token> | |
| 149 | 148 | gordon remotes use prod | |
| 150 | 149 | ||
| 151 | - | # Recommended first-time setup | |
| 152 | - | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 153 | - | ||
| 154 | - | # Then build, push, and deploy | |
| 155 | - | gordon push myapp:latest --domain app.example.com --build --no-confirm | |
| 150 | + | # Build and push the image (OCI transfer only, never deploys) | |
| 151 | + | gordon images push myapp --build --remote prod | |
| 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, | |
| 161 | - | and stores environment variables. | |
| 162 | - | - This is the recommended path for first deploys because it does not require the | |
| 163 | - | route to exist ahead of time. | |
| 164 | - | - Run `gordon push` separately after bootstrap to build, upload, and deploy the image. | |
| 156 | + | ```toml | |
| 157 | + | name = "blog" | |
| 158 | + | ||
| 159 | + | [services.web] | |
| 160 | + | image = "gordon.mydomain.com/myapp:latest" | |
| 161 | + | ||
| 162 | + | [[services.web.http]] | |
| 163 | + | host = "app.mydomain.com" | |
| 164 | + | port = 3000 | |
| 165 | + | ``` | |
| 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 | 169 | ```bash | |
| 169 | - | gordon push myapp --build --no-confirm | |
| 170 | + | 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 the | |
| 173 | - | deploy target. | |
| 174 | + | What these commands do: | |
| 175 | + | ||
| 176 | + | - `gordon images push` builds, uploads, and stores the image. It never deploys. | |
| 177 | + | - `gordon apps apply` validates the manifest and persists it as desired state. | |
| 178 | + | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | |
| 179 | + | ||
| 180 | + | If the app needs secrets, register their names in the manifest (`[services.<name>.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: | |
| 181 | + | ||
| 182 | + | ```bash | |
| 183 | + | gordon apps secrets set blog --service web APP_ENV=production --remote prod | |
| 184 | + | ``` | |
| 174 | 185 | ||
| 175 | 186 | Your app is now live at `https://app.mydomain.com`! | |
| 176 | 187 | ||
| 177 | 188 | ## 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 | 192 | ```bash | |
| 182 | - | # Make changes, then build + push + deploy | |
| 183 | - | gordon push myapp --build --no-confirm | |
| 193 | + | # Make changes, then build + push | |
| 194 | + | gordon images push myapp --build --remote prod | |
| 195 | + | ||
| 196 | + | # Deploy the new tag (re-resolves mutable tags) | |
| 197 | + | gordon apps deploy blog --remote prod | |
| 184 | 198 | ``` | |
| 185 | 199 | ||
| 186 | - | Gordon automatically: | |
| 187 | - | 1. Starts the new container | |
| 188 | - | 2. Waits for it to be ready | |
| 189 | - | 3. Routes traffic to the new container | |
| 190 | - | 4. Stops the old container | |
| 200 | + | Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services. | |
| 191 | 201 | ||
| 192 | 202 | ## Next Steps | |
| 193 | 203 | ||
| 194 | 204 | - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers | |
| 195 | - | - [Configuration Reference](./config/index.md) - All configuration options | |
| 205 | + | - [Configuration Reference](./config/index.md) - All installation configuration options | |
| 206 | + | - [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands | |
| 196 | 207 | - [Authentication](./config/auth.md) - Secure your registry | |
| 197 | - | - [Environment Variables](./config/env.md) - Configure per-app settings | |
| 208 | + | - [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass | |
| 198 | 209 | ||
| 199 | 210 | ## Related | |
| 200 | 211 | ||
M docs/index.md +10 -12
| @@ -1,15 +1,16 @@ | |||
| 1 | 1 | # 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 | 5 | ## What is Gordon? | |
| 6 | 6 | ||
| 7 | 7 | Gordon runs on your VPS and provides: | |
| 8 | 8 | ||
| 9 | 9 | - **Private Container Registry** - Push images from your local machine or CI | |
| 10 | - | - **HTTP Reverse Proxy** - Routes domains to containers automatically | |
| 11 | - | - **Push-to-Deploy** - Containers deploy when you push new images | |
| 12 | - | - **Zero-Downtime Updates** - New containers start before old ones stop | |
| 10 | + | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | |
| 11 | + | - **HTTP Reverse Proxy** - Routes app hosts to containers | |
| 12 | + | - **Push, Apply, Deploy** - Push stores images; deploy is always explicit | |
| 13 | + | - **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish | |
| 13 | 14 | - **Single Binary** - ~15MB RAM footprint | |
| 14 | 15 | ||
| 15 | 16 | ## How It Works | |
| @@ -31,7 +32,7 @@ Gordon runs on your VPS and provides: | |||
| 31 | 32 | ||
| 32 | 33 | 1. Build your container locally where you have computing power | |
| 33 | 34 | 2. Push to your Gordon registry | |
| 34 | - | 3. Gordon automatically deploys and routes traffic to your container | |
| 35 | + | 3. Apply the app manifest and deploy to activate it | |
| 35 | 36 | ||
| 36 | 37 | ## Quick Navigation | |
| 37 | 38 | ||
| @@ -46,15 +47,13 @@ Gordon runs on your VPS and provides: | |||
| 46 | 47 | ||
| 47 | 48 | - [Configuration Overview](./config/index.md) - All configuration options | |
| 48 | 49 | - [Server Settings](./config/server.md) - Ports, domains, and runtime | |
| 49 | - | - [Routes](./config/routes.md) - Domain to container mapping | |
| 50 | + | - [App Manifest](./config/apps.md) - Declarative app files (services, hosts, secrets, volumes) | |
| 51 | + | - [Migrate to Gordon v3](./migrate-to-v3.md) - Breaking upgrade and explicit secret migration | |
| 50 | 52 | - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints | |
| 51 | 53 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | |
| 52 | - | - [Secrets](./config/secrets.md) - Secure credential storage | |
| 53 | - | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | |
| 54 | - | - [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments | |
| 55 | - | - [Attachments](./config/attachments.md) - Service dependencies | |
| 54 | + | - [Secrets](./config/secrets.md) - Installation secrets and app secret values | |
| 55 | + | - [Network Isolation](./config/network-isolation.md) - Installation network policy | |
| 56 | 56 | - [Logging](./config/logging.md) - Log collection and rotation | |
| 57 | - | - [Environment Variables](./config/env.md) - Per-route environment configuration | |
| 58 | 57 | ||
| 59 | 58 | ### CLI Reference | |
| 60 | 59 | ||
| @@ -71,7 +70,6 @@ Gordon runs on your VPS and provides: | |||
| 71 | 70 | ### Reference | |
| 72 | 71 | ||
| 73 | 72 | - [Docker Labels](./reference/docker-labels.md) - Container and image labels | |
| 74 | - | - [Environment Variables](./reference/env-variables.md) - Environment variable syntax | |
| 75 | 73 | - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions | |
| 76 | 74 | ||
| 77 | 75 | ## Requirements | |
M docs/installation.md +41 -12
| @@ -19,7 +19,7 @@ Detailed installation guide for production environments. | |||
| 19 | 19 | set -euo pipefail | |
| 20 | 20 | installer="$(mktemp)" | |
| 21 | 21 | trap 'rm -f "$installer"' EXIT | |
| 22 | - | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | |
| 22 | + | curl -fsSL --output "$installer" https://bnema.dev/gordon/install | |
| 23 | 23 | if command -v less >/dev/null 2>&1; then | |
| 24 | 24 | less "$installer" | |
| 25 | 25 | 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 | 52 | ### Manual Installation | |
| 35 | 53 | ||
| @@ -136,6 +154,16 @@ insecure = true | |||
| 136 | 154 | 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 | 167 | ## Firewall Configuration | |
| 140 | 168 | ||
| 141 | 169 | Gordon needs ports accessible for the registry and the public edge entrypoint. | |
| @@ -195,11 +223,10 @@ gordon_domain = "gordon.yourdomain.com" | |||
| 195 | 223 | [entrypoints.edge] | |
| 196 | 224 | address = ":443" | |
| 197 | 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 | 230 | See [Configuration Reference](./config/index.md) for all options. | |
| 204 | 231 | ||
| 205 | 232 | ## Systemd Service | |
| @@ -310,17 +337,20 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica | |||
| 310 | 337 | ||
| 311 | 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-Release | |
| 340 | + | ## Choosing an Install Channel | |
| 314 | 341 | ||
| 315 | 342 | ```bash | |
| 316 | - | # Install an exact version | |
| 317 | - | curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash | |
| 343 | + | # Install an exact release | |
| 344 | + | curl -fsSL https://bnema.dev/gordon/install | GORDON_VERSION=v2.30.1 sh | |
| 345 | + | ||
| 346 | + | # Install the latest pre-release | |
| 347 | + | curl -fsSL https://bnema.dev/gordon/install | GORDON_PRERELEASE=1 sh | |
| 318 | 348 | ||
| 319 | - | # Install the latest pre-release instead of the latest stable release | |
| 320 | - | curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash | |
| 349 | + | # Build the current next branch commit from source | |
| 350 | + | curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh | |
| 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 | 355 | ## Verify Installation | |
| 326 | 356 | ||
| @@ -347,7 +377,6 @@ Gordon stores data in the following locations: | |||
| 347 | 377 | | `~/.config/gordon/gordon.toml` | Configuration file | | |
| 348 | 378 | | `~/.gordon/` | Default data directory | | |
| 349 | 379 | | `~/.gordon/registry/` | Container images | | |
| 350 | - | | `~/.gordon/env/` | Environment files | | |
| 351 | 380 | | `~/.gordon/logs/` | Application logs | | |
| 352 | 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 | 5 | ## 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 | 9 | | Label | Value | Description | | |
| 10 | 10 | |-------|-------|-------------| | |
| 11 | 11 | | `gordon.managed` | `"true"` | Identifies Gordon-managed containers | | |
| 12 | - | | `gordon.domain` | Domain name | Domain this container serves | | |
| 13 | - | | `gordon.image` | Image:tag | Original image from configuration | | |
| 14 | - | | `gordon.route` | Domain name | Route this container handles | | |
| 12 | + | | `gordon.app` | App name | App this container serves | | |
| 13 | + | | `gordon.app.service` | Service name | Service this container runs | | |
| 14 | + | | `gordon.app.revision` | Revision | Active revision that created it | | |
| 15 | 15 | | `gordon.created` | Timestamp | When Gordon created the container | | |
| 16 | 16 | ||
| 17 | - | ### Propagated Image Labels | |
| 18 | - | ||
| 19 | - | When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing): | |
| 20 | - | ||
| 21 | - | | Label | Example | Description | | |
| 22 | - | |-------|---------|-------------| | |
| 23 | - | | `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to | | |
| 24 | - | | `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing | | |
| 25 | - | ||
| 26 | - | These labels only appear on containers whose source image defined them. | |
| 17 | + | Queries by logical identity use labels, never name parsing. Resources without these labels (old or foreign containers) are preserved, never adopted or deleted. | |
| 27 | 18 | ||
| 28 | - | ### Attachment Labels | |
| 19 | + | ### 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 | 23 | | Label | Value | Description | | |
| 33 | 24 | |-------|-------|-------------| | |
| 34 | - | | `gordon.attachment` | `"true"` | Container is an attachment service | | |
| 35 | - | | `gordon.attached-to` | Domain/group | Route or network group this serves | | |
| 36 | - | ||
| 37 | - | ### Backup Labels | |
| 25 | + | | `gordon.domain` | Domain name | Pre-v3 domain this container served | | |
| 26 | + | | `gordon.image` | Image:tag | Original image from configuration | | |
| 27 | + | | `gordon.route` | Domain name | Pre-v3 route this container handled | | |
| 38 | 28 | ||
| 39 | - | Labels used by the backup subsystem: | |
| 29 | + | ### Backup Metadata | |
| 40 | 30 | ||
| 41 | - | | Label | Value | Description | | |
| 42 | - | |-------|-------|-------------| | |
| 43 | - | | `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container | | |
| 44 | - | | `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override | | |
| 45 | - | | `gordon.backup.version` | e.g. `"17"` | Explicit database version override | | |
| 46 | - | | `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint | | |
| 47 | - | | `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers | | |
| 31 | + | Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[services.<name>.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars. | |
| 48 | 32 | ||
| 49 | 33 | ## Image Labels | |
| 50 | 34 | ||
| 51 | - | Labels you can set in your Dockerfile: | |
| 52 | - | ||
| 53 | - | | Label | Example | Description | | |
| 54 | - | |-------|---------|-------------| | |
| 55 | - | | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route | | |
| 56 | - | | `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to | | |
| 57 | - | | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing | | |
| 58 | - | | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image | | |
| 59 | - | ||
| 60 | - | ### Health Check Label | |
| 61 | - | ||
| 62 | - | When `gordon.health` is set, Gordon performs HTTP GET requests to the specified | |
| 63 | - | path during deployment and waits for a 2xx or 3xx response before routing traffic | |
| 64 | - | to the new container: | |
| 65 | - | ||
| 66 | - | ```dockerfile | |
| 67 | - | FROM node:20-alpine | |
| 68 | - | LABEL gordon.health="/api/health" | |
| 69 | - | EXPOSE 3000 | |
| 70 | - | CMD ["node", "server.js"] | |
| 71 | - | ``` | |
| 72 | - | ||
| 73 | - | Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful | |
| 74 | - | response or the `deploy.http_probe_timeout` is reached. | |
| 75 | - | ||
| 76 | - | ### Proxy Port Label | |
| 77 | - | ||
| 78 | - | When an image exposes multiple ports, Gordon needs to know which one serves HTTP: | |
| 79 | - | ||
| 80 | - | ```dockerfile | |
| 81 | - | # Gitea exposes SSH (22) and HTTP (3000) | |
| 82 | - | FROM gitea/gitea:latest | |
| 83 | - | LABEL gordon.proxy.port=3000 | |
| 84 | - | EXPOSE 22 | |
| 85 | - | EXPOSE 3000 | |
| 86 | - | ``` | |
| 87 | - | ||
| 88 | - | Without this label, Gordon uses the first exposed port. | |
| 89 | - | ||
| 90 | - | **When to use:** | |
| 91 | - | - Image exposes multiple ports | |
| 92 | - | - First exposed port isn't the HTTP service | |
| 93 | - | - You want explicit control over routing | |
| 35 | + | No image-label inference exists in v3: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[services.<name>.readiness]`, `[[services.<name>.http]]`), not from image labels. | |
| 94 | 36 | ||
| 95 | 37 | ## Container Naming | |
| 96 | 38 | ||
| 97 | - | Gordon names containers: | |
| 98 | - | ||
| 99 | - | | Pattern | Example | | |
| 100 | - | |---------|---------| | |
| 101 | - | | `gordon-{domain}` | `gordon-app-mydomain-com` | | |
| 102 | - | | `gordon-{domain}-new` | `gordon-app-mydomain-com-new` (during updates) | | |
| 103 | - | | `gordon-{domain}-{service}` | `gordon-app-mydomain-com-postgres` (attachments) | | |
| 39 | + | Gordon names containers `gordon-<app>--<service>--<instance>` where `<instance>` is the creating operation ID. Retiring containers keep their instance names until removal. | |
| 104 | 40 | ||
| 105 | 41 | ## Inspecting Labels | |
| 106 | 42 | ||
| 107 | 43 | View labels on a container: | |
| 108 | 44 | ||
| 109 | 45 | ```bash | |
| 110 | - | docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | jq | |
| 46 | + | docker inspect <container> --format '{{json .Config.Labels}}' | jq | |
| 111 | 47 | ``` | |
| 112 | 48 | ||
| 113 | 49 | Example output: | |
| 114 | 50 | ||
| 115 | 51 | ```json | |
| 116 | 52 | { | |
| 117 | - | "gordon.created": "2024-01-15T10:30:00Z", | |
| 118 | - | "gordon.domain": "app.mydomain.com", | |
| 119 | - | "gordon.image": "myapp:latest", | |
| 120 | 53 | "gordon.managed": "true", | |
| 121 | - | "gordon.proxy.port": "3000", | |
| 122 | - | "gordon.route": "app.mydomain.com" | |
| 54 | + | "gordon.app": "blog", | |
| 55 | + | "gordon.app.service": "web", | |
| 56 | + | "gordon.app.revision": "rev-3", | |
| 57 | + | "gordon.created": "2024-01-15T10:30:00Z" | |
| 123 | 58 | } | |
| 124 | 59 | ``` | |
| 125 | 60 | ||
| @@ -131,75 +66,12 @@ Find Gordon-managed containers: | |||
| 131 | 66 | # All Gordon containers | |
| 132 | 67 | docker ps -f "label=gordon.managed=true" | |
| 133 | 68 | ||
| 134 | - | # Containers for specific domain | |
| 135 | - | docker ps -f "label=gordon.domain=app.mydomain.com" | |
| 136 | - | ||
| 137 | - | # Attachment containers | |
| 138 | - | docker ps -f "label=gordon.attachment=true" | |
| 139 | - | ||
| 140 | - | # Attachments for specific route | |
| 141 | - | docker ps -f "label=gordon.attached-to=app.mydomain.com" | |
| 142 | - | ||
| 143 | - | # Backup sidecars | |
| 144 | - | docker ps -f "label=gordon.backup.sidecar=true" | |
| 145 | - | ``` | |
| 146 | - | ||
| 147 | - | ## Examples | |
| 148 | - | ||
| 149 | - | ### Standard Application Container | |
| 150 | - | ||
| 151 | - | ```bash | |
| 152 | - | docker inspect gordon-app-mydomain-com --format '{{json .Config.Labels}}' | |
| 153 | - | ``` | |
| 154 | - | ||
| 155 | - | ```json | |
| 156 | - | { | |
| 157 | - | "gordon.managed": "true", | |
| 158 | - | "gordon.domain": "app.mydomain.com", | |
| 159 | - | "gordon.image": "myapp:v2.1.0", | |
| 160 | - | "gordon.route": "app.mydomain.com", | |
| 161 | - | "gordon.created": "2024-01-15T10:30:00Z" | |
| 162 | - | } | |
| 163 | - | ``` | |
| 164 | - | ||
| 165 | - | ### Attachment Container | |
| 166 | - | ||
| 167 | - | ```bash | |
| 168 | - | docker inspect gordon-app-mydomain-com-postgres --format '{{json .Config.Labels}}' | |
| 69 | + | # Containers for a specific app | |
| 70 | + | docker ps -f "label=gordon.app=blog" | |
| 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 | 73 | ## Related | |
| 202 | 74 | ||
| 203 | 75 | - [Configuration Overview](../config/index.md) | |
| 204 | - | - [Attachments](../config/attachments.md) | |
| 76 | + | - [App Manifest](../config/apps.md) | |
| 205 | 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 | 5 | ## Contents | |
| 6 | 6 | ||
| 7 | 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | |
| 8 | - | - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation | |
| 9 | 8 | - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | |
| 10 | 9 | - [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 | 91 | 1. Per-command flag: | |
| 92 | 92 | ||
| 93 | 93 | ```bash | |
| 94 | - | gordon push myapp:latest --insecure | |
| 94 | + | gordon images push myapp:latest --insecure | |
| 95 | 95 | ``` | |
| 96 | 96 | ||
| 97 | 97 | 2. Environment variable: | |
| @@ -118,17 +118,13 @@ No restart needed — Podman reads this on each pull/push. | |||
| 118 | 118 | ||
| 119 | 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: | |
| 124 | - | ```toml | |
| 125 | - | [routes] | |
| 126 | - | "app.mydomain.com" = "myapp:latest" | |
| 127 | - | ``` | |
| 123 | + | **Solution:** Push the image to the configured registry, verify the service's `image` reference in the app manifest, then apply and deploy the accepted revision: | |
| 128 | 124 | ||
| 129 | - | Then reload: | |
| 130 | 125 | ```bash | |
| 131 | - | gordon reload | |
| 126 | + | gordon apps apply --file app.toml | |
| 127 | + | gordon apps deploy app | |
| 132 | 128 | ``` | |
| 133 | 129 | ||
| 134 | 130 | ## Deployment Issues | |
| @@ -161,14 +157,14 @@ gordon reload | |||
| 161 | 157 | ||
| 162 | 158 | **Solutions:** | |
| 163 | 159 | ||
| 164 | - | 1. Check container logs: | |
| 160 | + | 1. Check workload logs: | |
| 165 | 161 | ```bash | |
| 166 | - | docker logs gordon-app-mydomain-com | |
| 162 | + | gordon apps logs blog --service web | |
| 167 | 163 | ``` | |
| 168 | 164 | ||
| 169 | 165 | 2. Check Gordon logs: | |
| 170 | 166 | ```bash | |
| 171 | - | gordon logs -f | |
| 167 | + | gordon daemon logs -f | |
| 172 | 168 | ``` | |
| 173 | 169 | ||
| 174 | 170 | 3. Run container manually to debug: | |
| @@ -178,28 +174,23 @@ gordon reload | |||
| 178 | 174 | ||
| 179 | 175 | ### 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 | 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 | 182 | ```bash | |
| 187 | - | ls ~/.gordon/env/ | |
| 188 | - | # Should show: app_mydomain_com.env (dots → underscores) | |
| 183 | + | gordon apps apply --file ./blog.toml | |
| 189 | 184 | ``` | |
| 190 | 185 | ||
| 191 | - | 2. Check file permissions: | |
| 186 | + | 2. Check which secret values are set (names only): | |
| 192 | 187 | ```bash | |
| 193 | - | chmod 600 ~/.gordon/env/app_mydomain_com.env | |
| 188 | + | 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 | 192 | ```bash | |
| 198 | - | # Correct | |
| 199 | - | KEY=value | |
| 200 | - | ||
| 201 | - | # Wrong | |
| 202 | - | KEY = value | |
| 193 | + | gordon apps restart blog | |
| 203 | 194 | ``` | |
| 204 | 195 | ||
| 205 | 196 | ### Secrets not resolved | |
| @@ -234,18 +225,14 @@ gordon reload | |||
| 234 | 225 | ||
| 235 | 226 | **Solutions:** | |
| 236 | 227 | ||
| 237 | - | 1. Check attachments are configured: | |
| 238 | - | ```toml | |
| 239 | - | [attachments] | |
| 240 | - | "app.mydomain.com" = ["postgres:latest"] | |
| 241 | - | ``` | |
| 228 | + | 1. Check that both services declare the same shared network in the app manifest. | |
| 242 | 229 | ||
| 243 | - | 2. Check containers are in same network: | |
| 230 | + | 2. Check containers are attached to that network: | |
| 244 | 231 | ```bash | |
| 245 | - | docker network inspect gordon-app-mydomain-com | |
| 232 | + | 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 | 236 | ```javascript | |
| 250 | 237 | // Correct | |
| 251 | 238 | connect("postgresql://postgres:5432/mydb") | |
| @@ -326,7 +313,7 @@ gordon reload | |||
| 326 | 313 | ||
| 327 | 314 | **Solution:** Manual reload: | |
| 328 | 315 | ```bash | |
| 329 | - | gordon reload | |
| 316 | + | gordon daemon reload | |
| 330 | 317 | ``` | |
| 331 | 318 | ||
| 332 | 319 | ### Stale `targets.toml` in config directory | |
| @@ -369,15 +356,9 @@ enabled = true | |||
| 369 | 356 | path = "~/.gordon/logs/gordon.log" | |
| 370 | 357 | ``` | |
| 371 | 358 | ||
| 372 | - | ### Container logs missing | |
| 373 | - | ||
| 374 | - | **Cause:** Container log collection disabled. | |
| 359 | + | ### Workload logs unavailable | |
| 375 | 360 | ||
| 376 | - | **Solution:** | |
| 377 | - | ```toml | |
| 378 | - | [logging.container_logs] | |
| 379 | - | enabled = true # default: true | |
| 380 | - | ``` | |
| 361 | + | `gordon apps logs APP --service SERVICE` reads directly from the container runtime. Confirm that the app has an active deployment, use the exact service name, and check the runtime's logging driver and retention settings. Gordon does not write workload logs to `logging.container_logs` files. | |
| 381 | 362 | ||
| 382 | 363 | ## Diagnostic Commands | |
| 383 | 364 | ||
| @@ -386,7 +367,7 @@ enabled = true # default: true | |||
| 386 | 367 | systemctl --user status gordon | |
| 387 | 368 | ||
| 388 | 369 | # View Gordon logs | |
| 389 | - | gordon logs -f | |
| 370 | + | gordon daemon logs -f | |
| 390 | 371 | journalctl --user -u gordon -f | |
| 391 | 372 | ||
| 392 | 373 | # List containers | |
| @@ -398,11 +379,11 @@ docker network ls | grep gordon | |||
| 398 | 379 | # List volumes | |
| 399 | 380 | docker volume ls | grep gordon | |
| 400 | 381 | ||
| 401 | - | # Check container logs | |
| 402 | - | docker logs gordon-app-mydomain-com | |
| 382 | + | # Check workload logs through Gordon | |
| 383 | + | gordon apps logs blog --service web | |
| 403 | 384 | ||
| 404 | - | # Inspect container | |
| 405 | - | docker inspect gordon-app-mydomain-com | |
| 385 | + | # Inspect runtime containers when diagnosing locally | |
| 386 | + | docker ps -f "label=gordon.app=blog" | |
| 406 | 387 | ||
| 407 | 388 | # Check connectivity | |
| 408 | 389 | curl -v http://localhost:5000/v2/ | |
M docs/upgrading.md +67 -29
| @@ -2,11 +2,69 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 63 | ## Route-Domain Validation | |
| 6 | 64 | ||
| 7 | 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 entrypoints | |
| 67 | + | ## v2.31.0: Unified smart TCP entrypoints | |
| 10 | 68 | ||
| 11 | 69 | ### 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 | 117 | - TLS-ALPN-01 is unsupported. | |
| 60 | 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 | 120 | ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips` | |
| 83 | 121 | ||
| 84 | 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 | 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 | 157 | ### Staged Registry Host Rename | |
| 120 | 158 | ||
| @@ -214,7 +252,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based | |||
| 214 | 252 | - `gordon auth show-token` prints the stored token for a remote | |
| 215 | 253 | - `gordon auth logout` removes the stored token locally | |
| 216 | 254 | - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls | |
| 217 | - | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |
| 255 | + | - Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations | |
| 218 | 256 | ||
| 219 | 257 | **Migration steps:** | |
| 220 | 258 | ||
| @@ -262,7 +300,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based | |||
| 262 | 300 | 5. **Upgrade the binary** and restart: | |
| 263 | 301 | ||
| 264 | 302 | ```bash | |
| 265 | - | curl -fsSL https://gordon.bnema.dev/install | bash | |
| 303 | + | curl -fsSL https://bnema.dev/gordon/install | bash | |
| 266 | 304 | systemctl --user restart gordon | |
| 267 | 305 | ``` | |
| 268 | 306 | ||
| @@ -294,8 +332,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi | |||
| 294 | 332 | # Read-only monitoring | |
| 295 | 333 | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |
| 296 | 334 | ||
| 297 | - | # CI deploy with route read + config write | |
| 298 | - | gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0 | |
| 335 | + | # CI deploy with app read + write | |
| 336 | + | gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0 | |
| 299 | 337 | ``` | |
| 300 | 338 | ||
| 301 | 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 | 471 | 3. **Test in staging** if possible | |
| 434 | 472 | 4. **Upgrade the binary**: | |
| 435 | 473 | ```bash | |
| 436 | - | curl -fsSL https://gordon.bnema.dev/install | bash | |
| 474 | + | curl -fsSL https://bnema.dev/gordon/install | bash | |
| 437 | 475 | ``` | |
| 438 | 476 | 5. **Restart Gordon**: | |
| 439 | 477 | ```bash | |
| @@ -441,10 +479,10 @@ If using pass or sops, update your secret paths: | |||
| 441 | 479 | ``` | |
| 442 | 480 | 6. **Check logs** for any errors: | |
| 443 | 481 | ```bash | |
| 444 | - | gordon logs | |
| 482 | + | gordon daemon logs | |
| 445 | 483 | ``` | |
| 446 | 484 | ||
| 447 | 485 | ## Getting Help | |
| 448 | 486 | ||
| 449 | 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 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/aws/aws-sdk-go-v2 v1.45.1 | |
| 7 | - | github.com/aws/aws-sdk-go-v2/config v1.33.1 | |
| 8 | - | github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.1 | |
| 9 | - | github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 | |
| 6 | + | github.com/aws/aws-sdk-go-v2 v1.47.1 | |
| 7 | + | github.com/aws/aws-sdk-go-v2/config v1.33.6 | |
| 8 | + | github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.4.10 | |
| 9 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 | |
| 10 | 10 | github.com/bnema/zerowrap v1.4.1 | |
| 11 | 11 | github.com/charmbracelet/bubbles v1.0.0 | |
| 12 | 12 | github.com/charmbracelet/bubbletea v1.3.10 | |
| @@ -17,12 +17,12 @@ require ( | |||
| 17 | 17 | github.com/fsnotify/fsnotify v1.10.1 // direct | |
| 18 | 18 | github.com/go-acme/lego/v4 v4.35.2 | |
| 19 | 19 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 20 | - | github.com/google/go-containerregistry v0.22.0 | |
| 20 | + | github.com/google/go-containerregistry v0.22.1 | |
| 21 | 21 | github.com/google/uuid v1.6.0 | |
| 22 | 22 | github.com/mattn/go-isatty v0.0.24 | |
| 23 | - | github.com/mattn/go-runewidth v0.0.28 | |
| 24 | - | github.com/moby/moby/api v1.55.0 | |
| 25 | - | github.com/moby/moby/client v0.5.1 | |
| 23 | + | github.com/mattn/go-runewidth v0.0.30 | |
| 24 | + | github.com/moby/moby/api v1.56.0 | |
| 25 | + | github.com/moby/moby/client v0.6.0 | |
| 26 | 26 | github.com/muesli/termenv v0.16.0 | |
| 27 | 27 | github.com/pelletier/go-toml/v2 v2.4.3 | |
| 28 | 28 | github.com/rivo/uniseg v0.4.7 | |
| @@ -32,21 +32,22 @@ require ( | |||
| 32 | 32 | github.com/spf13/cobra v1.10.2 | |
| 33 | 33 | github.com/spf13/viper v1.21.0 | |
| 34 | 34 | github.com/stretchr/testify v1.12.1 | |
| 35 | + | go.etcd.io/bbolt v1.5.0 | |
| 35 | 36 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 | |
| 36 | 37 | go.opentelemetry.io/otel v1.46.0 | |
| 37 | 38 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 | |
| 38 | 39 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 | |
| 39 | 40 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 | |
| 41 | + | go.opentelemetry.io/otel/log v0.22.0 | |
| 40 | 42 | go.opentelemetry.io/otel/metric v1.46.0 | |
| 41 | 43 | go.opentelemetry.io/otel/sdk v1.46.0 | |
| 42 | 44 | go.opentelemetry.io/otel/sdk/log v0.22.0 | |
| 43 | 45 | go.opentelemetry.io/otel/sdk/metric v1.46.0 | |
| 44 | 46 | go.opentelemetry.io/otel/trace v1.46.0 | |
| 45 | 47 | go.uber.org/mock v0.6.0 // direct | |
| 46 | - | golang.org/x/mod v0.40.0 | |
| 47 | - | golang.org/x/sync v0.22.0 | |
| 48 | - | golang.org/x/sys v0.47.0 | |
| 49 | - | golang.org/x/time v0.15.0 | |
| 48 | + | golang.org/x/sync v0.23.0 | |
| 49 | + | golang.org/x/sys v0.48.0 | |
| 50 | + | golang.org/x/time v0.16.0 | |
| 50 | 51 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 51 | 52 | ) | |
| 52 | 53 | ||
| @@ -54,71 +55,69 @@ require ( | |||
| 54 | 55 | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 55 | 56 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 56 | 57 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect | |
| 57 | - | github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect | |
| 58 | - | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect | |
| 59 | - | github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect | |
| 60 | - | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect | |
| 61 | - | github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect | |
| 58 | + | github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect | |
| 59 | + | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect | |
| 60 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect | |
| 61 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect | |
| 62 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect | |
| 62 | 63 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect | |
| 63 | - | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect | |
| 64 | - | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect | |
| 65 | - | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect | |
| 66 | - | github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect | |
| 67 | - | github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect | |
| 68 | - | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect | |
| 69 | - | github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect | |
| 70 | - | github.com/aws/smithy-go v1.28.1 // indirect | |
| 64 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect | |
| 65 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect | |
| 66 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect | |
| 67 | + | github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect | |
| 68 | + | github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect | |
| 69 | + | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect | |
| 70 | + | github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect | |
| 71 | + | github.com/aws/smithy-go v1.28.2 // indirect | |
| 71 | 72 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 72 | 73 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | |
| 73 | 74 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 74 | 75 | github.com/charmbracelet/colorprofile v0.4.3 // indirect | |
| 75 | - | github.com/charmbracelet/x/ansi v0.11.6 // indirect | |
| 76 | + | github.com/charmbracelet/x/ansi v0.11.8 // indirect | |
| 76 | 77 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | |
| 77 | 78 | github.com/charmbracelet/x/term v0.2.2 // indirect | |
| 78 | 79 | github.com/clipperhouse/displaywidth v0.11.0 // indirect | |
| 79 | 80 | github.com/clipperhouse/uax29/v2 v2.7.0 // indirect | |
| 80 | 81 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 81 | 82 | github.com/distribution/reference v0.6.0 // indirect | |
| 82 | - | github.com/docker/cli v29.7.2+incompatible // indirect | |
| 83 | - | github.com/docker/docker-credential-helpers v0.9.5 // indirect | |
| 83 | + | github.com/docker/cli v29.8.1+incompatible // indirect | |
| 84 | + | github.com/docker/docker-credential-helpers v0.9.9 // indirect | |
| 84 | 85 | github.com/docker/go-connections v0.8.1 // indirect | |
| 85 | 86 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 86 | 87 | github.com/felixge/httpsnoop v1.1.0 // indirect | |
| 87 | - | github.com/go-jose/go-jose/v4 v4.1.4 // indirect | |
| 88 | + | github.com/go-jose/go-jose/v4 v4.1.5 // indirect | |
| 88 | 89 | github.com/go-logr/logr v1.4.4 // indirect | |
| 89 | 90 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 90 | 91 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 91 | - | github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect | |
| 92 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect | |
| 92 | 93 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 93 | - | github.com/klauspost/compress v1.19.2 // indirect | |
| 94 | - | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | |
| 94 | + | github.com/klauspost/compress v1.20.1 // indirect | |
| 95 | + | github.com/lucasb-eyer/go-colorful v1.4.1 // indirect | |
| 95 | 96 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 96 | 97 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 97 | - | github.com/miekg/dns v1.1.72 // indirect | |
| 98 | + | github.com/miekg/dns v1.1.73 // indirect | |
| 98 | 99 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 99 | 100 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 100 | 101 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 101 | 102 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 102 | 103 | github.com/opencontainers/image-spec v1.1.1 // indirect | |
| 103 | 104 | github.com/sagikazarmark/locafero v0.12.0 // indirect | |
| 104 | - | github.com/sirupsen/logrus v1.9.4 // indirect | |
| 105 | + | github.com/sirupsen/logrus v1.10.2 // indirect | |
| 105 | 106 | github.com/spf13/cast v1.10.0 // indirect | |
| 106 | 107 | github.com/spf13/pflag v1.0.10 // indirect | |
| 107 | 108 | github.com/stretchr/objx v0.5.3 // indirect | |
| 108 | 109 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 109 | - | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| 110 | + | github.com/xo/terminfo v1.2.0 // indirect | |
| 110 | 111 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | |
| 111 | 112 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect | |
| 112 | - | go.opentelemetry.io/otel/log v0.22.0 // indirect | |
| 113 | 113 | go.opentelemetry.io/proto/otlp v1.11.0 // indirect | |
| 114 | 114 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 115 | - | golang.org/x/crypto v0.55.0 // indirect | |
| 116 | - | golang.org/x/net v0.58.0 // indirect | |
| 117 | - | golang.org/x/text v0.41.0 // indirect | |
| 118 | - | golang.org/x/tools v0.49.0 // indirect | |
| 119 | - | google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect | |
| 120 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect | |
| 121 | - | google.golang.org/grpc v1.83.1 // indirect | |
| 115 | + | golang.org/x/crypto v0.57.0 // indirect | |
| 116 | + | golang.org/x/net v0.59.0 // indirect | |
| 117 | + | golang.org/x/text v0.42.0 // indirect | |
| 118 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect | |
| 119 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect | |
| 120 | + | google.golang.org/grpc v1.84.0 // indirect | |
| 122 | 121 | google.golang.org/protobuf v1.36.12 // indirect | |
| 123 | 122 | 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 | 55 | burst = 100 | |
| 56 | 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 | 58 | [containers] | |
| 68 | 59 | # "compat" preserves broad image compatibility. "strict" enables a read-only | |
| 69 | 60 | # root filesystem and narrower Linux capabilities. | |
| 70 | 61 | security_profile = "compat" | |
| 71 | 62 | ||
| 72 | 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 | 67 | enabled = true | |
| 74 | 68 | network_prefix = "gordon" | |
| 75 | 69 | # Set true to create Docker internal networks without direct external egress. | |
| @@ -81,10 +75,10 @@ prefix = "gordon" | |||
| 81 | 75 | preserve = true | |
| 82 | 76 | ||
| 83 | 77 | [images] | |
| 84 | - | # Explicit external registries must be listed here. Gordon's own registry is | |
| 85 | - | # always allowed; localhost/private/link-local registries are always rejected. | |
| 78 | + | # Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed. | |
| 79 | + | # Add other registries as exact hostname+port entries; this does not configure credentials. | |
| 86 | 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 | 82 | require_digest = false | |
| 89 | 83 | ||
| 90 | 84 | [images.prune] | |
| @@ -92,9 +86,6 @@ enabled = false | |||
| 92 | 86 | schedule = "daily" | |
| 93 | 87 | keep_last = 3 | |
| 94 | 88 | ||
| 95 | - | [auto_route] | |
| 96 | - | enabled = false | |
| 97 | - | ||
| 98 | 89 | [logging] | |
| 99 | 90 | level = "info" | |
| 100 | 91 | format = "console" | |
| @@ -106,13 +97,6 @@ enabled = false | |||
| 106 | 97 | # max_backups = 3 | |
| 107 | 98 | # 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 | 100 | [logging.access_log] | |
| 117 | 101 | enabled = false | |
| 118 | 102 | format = "json" | |
| @@ -124,6 +108,40 @@ max_age = 28 | |||
| 124 | 108 | exclude_health_checks = true | |
| 125 | 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 | 145 | [telemetry] | |
| 128 | 146 | enabled = false | |
| 129 | 147 | # endpoint = "http://localhost:4318" | |
| @@ -133,9 +151,6 @@ metrics = true | |||
| 133 | 151 | logs = true | |
| 134 | 152 | trace_sample_rate = 1.0 | |
| 135 | 153 | ||
| 136 | - | [env] | |
| 137 | - | # dir = "~/.gordon/env" | |
| 138 | - | ||
| 139 | 154 | [backups.databases] | |
| 140 | 155 | enabled = false | |
| 141 | 156 | schedule = "daily" | |
| @@ -168,14 +183,33 @@ prefix = "" | |||
| 168 | 183 | [backups.volumes.retention] | |
| 169 | 184 | keep = 14 | |
| 170 | 185 | ||
| 171 | - | [routes] | |
| 186 | + | # [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed. | |
| 172 | 187 | # "app.example.com" = { image = "myapp:latest" } | |
| 173 | 188 | ||
| 174 | 189 | [external_routes] | |
| 175 | 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 | 208 | # 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 | 213 | # [[services]] | |
| 180 | 214 | # name = "rust" | |
| 181 | 215 | # image = "registry.example.com:5000/rust:latest" | |
| @@ -213,9 +247,6 @@ keep = 14 | |||
| 213 | 247 | # name = "rust-rcon" | |
| 214 | 248 | # entrypoint = "rcon" | |
| 215 | 249 | # service = "service:rust:rcon" | |
| 216 | - | ||
| 217 | - | [network_groups] | |
| 218 | - | # "backend" = ["app.example.com", "api.example.com"] | |
| 219 | - | ||
| 220 | - | [attachments] | |
| 221 | - | # "app.example.com" = ["postgres:18"] | |
| 250 | + | # | |
| 251 | + | # REMOVED application sections (see note under [external_routes] above): | |
| 252 | + | # [routes], [network_groups], [attachments] were deleted with the cutover. | |
M install.sh +303 -18
| @@ -2,12 +2,55 @@ | |||
| 2 | 2 | set -e | |
| 3 | 3 | ||
| 4 | 4 | # Gordon installer script | |
| 5 | - | # Usage: curl -fsSL https://gordon.bnema.dev/install | bash | |
| 6 | - | # Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash | |
| 5 | + | # Usage: curl -fsSL https://bnema.dev/gordon/install | sh | |
| 6 | + | # Usage with pre-release: curl -fsSL https://bnema.dev/gordon/install | GORDON_PRERELEASE=1 sh | |
| 7 | + | # Usage with next source build: curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh | |
| 7 | 8 | ||
| 8 | 9 | REPO="bnema/gordon" | |
| 9 | - | INSTALL_DIR="/usr/local/bin" | |
| 10 | 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 | 55 | echo "Installing Gordon..." | |
| 13 | 56 | ||
| @@ -43,6 +86,259 @@ esac | |||
| 43 | 86 | ||
| 44 | 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 | 342 | # Construct download URL | |
| 47 | 343 | TARBALL="gordon_${OS}_${ARCH}.tar.gz" | |
| 48 | 344 | ||
| @@ -101,6 +397,8 @@ fi | |||
| 101 | 397 | ||
| 102 | 398 | # Verify checksum | |
| 103 | 399 | echo "Verifying checksum..." | |
| 400 | + | # The sed expression is intentionally literal; shell expansion would corrupt it. | |
| 401 | + | # shellcheck disable=SC2016 | |
| 104 | 402 | TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g') | |
| 105 | 403 | CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true) | |
| 106 | 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 | 445 | fi | |
| 148 | 446 | ||
| 149 | 447 | echo "Installing to ${INSTALL_DIR}..." | |
| 150 | - | if [ -w "$INSTALL_DIR" ]; then | |
| 151 | - | install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 152 | - | else | |
| 153 | - | echo "sudo required to install to ${INSTALL_DIR}" | |
| 154 | - | sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 155 | - | fi | |
| 448 | + | install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon" | |
| 156 | 449 | ||
| 157 | - | # Verify installation | |
| 158 | - | if command -v gordon >/dev/null 2>&1; then | |
| 159 | - | echo "" | |
| 160 | - | echo "Gordon installed successfully!" | |
| 161 | - | gordon version | |
| 162 | - | else | |
| 163 | - | echo "" | |
| 164 | - | echo "Installation complete. You may need to add ${INSTALL_DIR} to your PATH." | |
| 165 | - | fi | |
| 450 | + | post_install | |
A internal/adapters/dto/admin_apps.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // App admin DTOs use stable wire-facing field names. | |
| 6 | + | // No secret values exist in any shape here: secret references carry | |
| 7 | + | // paths and env keys only. The versioned error envelope is a deliberate | |
| 8 | + | // change from ErrorResponse for app mutations; clients accept both | |
| 9 | + | // during the mixed-version window. | |
| 10 | + | ||
| 11 | + | // AppError is the v3 app-mutation error envelope. Its stable fields are | |
| 12 | + | // error/message/cause/hint. | |
| 13 | + | // `logs` never appears on mutations. Clients accept the legacy | |
| 14 | + | // single-field ErrorResponse during the mixed-version window. | |
| 15 | + | type AppError struct { | |
| 16 | + | Error string `json:"error"` | |
| 17 | + | Message string `json:"message,omitempty"` | |
| 18 | + | Cause string `json:"cause,omitempty"` | |
| 19 | + | Hint string `json:"hint,omitempty"` | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // AppDiffSection carries normalized added/removed/changed paths. | |
| 23 | + | type AppDiffSection struct { | |
| 24 | + | Added []string `json:"added"` | |
| 25 | + | Removed []string `json:"removed"` | |
| 26 | + | Changed []string `json:"changed"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // AppApplyRequest asks the daemon to validate and persist desired state. | |
| 30 | + | type AppApplyRequest struct { | |
| 31 | + | ManifestTOML string `json:"manifest_toml"` | |
| 32 | + | DryRun bool `json:"dry_run,omitempty"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // AppApplyResponse reports persistence success separately from deploy. | |
| 36 | + | type AppApplyResponse struct { | |
| 37 | + | App string `json:"app"` | |
| 38 | + | FormerRevision string `json:"former_revision,omitempty"` | |
| 39 | + | ResultingRevision string `json:"resulting_revision,omitempty"` | |
| 40 | + | Pending bool `json:"pending"` | |
| 41 | + | Noop bool `json:"noop,omitempty"` | |
| 42 | + | Diff AppDiffSection `json:"diff"` | |
| 43 | + | Intent string `json:"intent,omitempty"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // AppDeployRequest activates a captured revision. | |
| 47 | + | type AppDeployRequest struct { | |
| 48 | + | Revision string `json:"revision,omitempty"` | |
| 49 | + | Service string `json:"service,omitempty"` | |
| 50 | + | // All confirms an app-wide deploy of a multi-service app. | |
| 51 | + | All bool `json:"all,omitempty"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // AppServiceResultDTO is one service's terminal deployment result. | |
| 55 | + | type AppServiceResultDTO struct { | |
| 56 | + | Result string `json:"result"` | |
| 57 | + | EffectiveRevision string `json:"effective_revision"` | |
| 58 | + | Before string `json:"before,omitempty"` | |
| 59 | + | After string `json:"after,omitempty"` | |
| 60 | + | RestartUnsafe bool `json:"restart_unsafe"` | |
| 61 | + | Error string `json:"error,omitempty"` | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // AppStepDTO is one journaled operation step. | |
| 65 | + | type AppStepDTO struct { | |
| 66 | + | ID string `json:"id"` | |
| 67 | + | State string `json:"state"` | |
| 68 | + | Detail string `json:"detail,omitempty"` | |
| 69 | + | Error string `json:"error,omitempty"` | |
| 70 | + | Before string `json:"before,omitempty"` | |
| 71 | + | After string `json:"after,omitempty"` | |
| 72 | + | // Diagnostics is redacted failure output, populated only for callers | |
| 73 | + | // holding the logs read scope. | |
| 74 | + | Diagnostics []string `json:"diagnostics,omitempty"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // AppCleanupWarningDTO records post-publication leftovers. | |
| 78 | + | type AppCleanupWarningDTO struct { | |
| 79 | + | Service string `json:"service"` | |
| 80 | + | Leftover string `json:"leftover"` | |
| 81 | + | Detail string `json:"detail"` | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // AppEffectiveDTO maps services to effective revisions. | |
| 85 | + | type AppEffectiveDTO struct { | |
| 86 | + | Converged bool `json:"converged"` | |
| 87 | + | ConvergedRevision string `json:"converged_revision,omitempty"` | |
| 88 | + | Services map[string]string `json:"services"` | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // AppRetainedDTO lists the resources an app owns and would retain on | |
| 92 | + | // removal (names and paths, never values). | |
| 93 | + | type AppRetainedDTO struct { | |
| 94 | + | Volumes []string `json:"volumes"` | |
| 95 | + | Secrets []string `json:"secrets"` | |
| 96 | + | Images []string `json:"images"` | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // AppSecretMetadataDTO reports registration metadata without secret values. | |
| 100 | + | type AppSecretMetadataDTO struct { | |
| 101 | + | Service string `json:"service"` | |
| 102 | + | Key string `json:"key"` | |
| 103 | + | Name string `json:"name"` | |
| 104 | + | Source string `json:"source"` | |
| 105 | + | Presence string `json:"presence"` | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // AppStatusRunning is the wire status of an operation whose effects are | |
| 109 | + | // still executing in the background. A terminal operation reports its | |
| 110 | + | // persisted outcome as the status instead. | |
| 111 | + | const AppStatusRunning = "running" | |
| 112 | + | ||
| 113 | + | // AppDeployResponse reports deploy outcome with terminal results. | |
| 114 | + | // Effective and Retained are filled from current app state when the app | |
| 115 | + | // still exists; they are omitted when it does not. | |
| 116 | + | type AppDeployResponse struct { | |
| 117 | + | Op string `json:"op"` | |
| 118 | + | App string `json:"app"` | |
| 119 | + | Revision string `json:"revision"` | |
| 120 | + | Status string `json:"status"` | |
| 121 | + | Outcome string `json:"outcome"` | |
| 122 | + | Services map[string]AppServiceResultDTO `json:"services"` | |
| 123 | + | Steps []AppStepDTO `json:"steps"` | |
| 124 | + | CleanupWarnings []AppCleanupWarningDTO `json:"cleanup_warnings,omitempty"` | |
| 125 | + | Effective *AppEffectiveDTO `json:"effective,omitempty"` | |
| 126 | + | Retained *AppRetainedDTO `json:"retained,omitempty"` | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // AppDesiredDTO summarizes desired state. | |
| 130 | + | type AppDesiredDTO struct { | |
| 131 | + | Revision string `json:"revision,omitempty"` | |
| 132 | + | Status string `json:"status,omitempty"` | |
| 133 | + | // Pending reports desired state ACTIVE has not reached yet. | |
| 134 | + | Pending bool `json:"pending"` | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // AppActiveServiceDTO is one effective service for inspection. | |
| 138 | + | type AppActiveServiceDTO struct { | |
| 139 | + | EffectiveRevision string `json:"effective_revision"` | |
| 140 | + | Digest string `json:"digest,omitempty"` | |
| 141 | + | Container string `json:"container,omitempty"` | |
| 142 | + | RestartUnsafe bool `json:"restart_unsafe"` | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // AppActiveDTO carries per-service effective state. | |
| 146 | + | type AppActiveDTO struct { | |
| 147 | + | Converged bool `json:"converged"` | |
| 148 | + | ConvergedRevision string `json:"converged_revision,omitempty"` | |
| 149 | + | Services map[string]AppActiveServiceDTO `json:"services"` | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // AppIntentDTO carries durable stopped/running intent. | |
| 153 | + | type AppIntentDTO struct { | |
| 154 | + | Stopped bool `json:"stopped"` | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // AppLastOpDTO references the latest operation. | |
| 158 | + | type AppLastOpDTO struct { | |
| 159 | + | Op string `json:"op"` | |
| 160 | + | Kind string `json:"kind,omitempty"` | |
| 161 | + | Outcome string `json:"outcome,omitempty"` | |
| 162 | + | StartedAt time.Time `json:"started_at,omitzero"` | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // AppShowResponse inspects desired + active + intent + ownership + op ref. | |
| 166 | + | type AppShowResponse struct { | |
| 167 | + | App string `json:"app"` | |
| 168 | + | Desired AppDesiredDTO `json:"desired"` | |
| 169 | + | Active AppActiveDTO `json:"active"` | |
| 170 | + | Intent AppIntentDTO `json:"intent"` | |
| 171 | + | Retained AppRetainedDTO `json:"retained"` | |
| 172 | + | LastOp *AppLastOpDTO `json:"last_op,omitempty"` | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // AppSummaryDTO is one row of the app list. | |
| 176 | + | type AppSummaryDTO struct { | |
| 177 | + | App string `json:"app"` | |
| 178 | + | Desired string `json:"desired,omitempty"` | |
| 179 | + | DesiredStatus string `json:"desired_status,omitempty"` | |
| 180 | + | Active string `json:"active,omitempty"` | |
| 181 | + | Converged bool `json:"converged"` | |
| 182 | + | Pending bool `json:"pending"` | |
| 183 | + | Stopped bool `json:"stopped"` | |
| 184 | + | LastOutcome string `json:"last_outcome,omitempty"` | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // AppDiffResponse reports normalized desired-vs-active diff. | |
| 188 | + | type AppDiffResponse struct { | |
| 189 | + | App string `json:"app"` | |
| 190 | + | Diff AppDiffSection `json:"diff"` | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // AppSecretSetRequest writes secret values (names pre-registered). | |
| 194 | + | type AppSecretSetRequest struct { | |
| 195 | + | Service string `json:"service"` | |
| 196 | + | Secrets map[string]string `json:"secrets"` | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // AppSecretDeleteRequest removes one secret value. | |
| 200 | + | type AppSecretDeleteRequest struct { | |
| 201 | + | Service string `json:"service"` | |
| 202 | + | Key string `json:"key"` | |
| 203 | + | } | |
A internal/adapters/dto/admin_apps_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // Frozen-wire tests for the v3 app admin DTOs pin the exact JSON field | |
| 4 | + | // names the daemon and the CLI exchange, and prove no response shape can | |
| 5 | + | // carry secret values. The single intentional exception is | |
| 6 | + | // AppSecretSetRequest: the write path must transport values. | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/json" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func dtoJSONKeys(t *testing.T, v any) map[string]any { | |
| 18 | + | t.Helper() | |
| 19 | + | raw, err := json.Marshal(v) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | var decoded map[string]any | |
| 22 | + | require.NoError(t, json.Unmarshal(raw, &decoded)) | |
| 23 | + | return decoded | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAppErrorEnvelopeKeys(t *testing.T) { | |
| 27 | + | envelope := AppError{Error: "secret-missing", Message: "db password missing", Cause: "pass lookup failed", Hint: "set it first"} | |
| 28 | + | keys := dtoJSONKeys(t, envelope) | |
| 29 | + | assert.Equal(t, "secret-missing", keys["error"]) | |
| 30 | + | assert.Equal(t, "db password missing", keys["message"]) | |
| 31 | + | assert.Equal(t, "pass lookup failed", keys["cause"]) | |
| 32 | + | assert.Equal(t, "set it first", keys["hint"]) | |
| 33 | + | assert.NotContains(t, keys, "logs", "mutations must never carry logs") | |
| 34 | + | ||
| 35 | + | // Mixed-version window: the legacy single-field shape still decodes. | |
| 36 | + | var legacy AppError | |
| 37 | + | require.NoError(t, json.Unmarshal([]byte(`{"error":"image-unresolvable"}`), &legacy)) | |
| 38 | + | assert.Equal(t, "image-unresolvable", legacy.Error) | |
| 39 | + | assert.Empty(t, legacy.Message) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestAppApplyResponseKeys(t *testing.T) { | |
| 43 | + | resp := AppApplyResponse{ | |
| 44 | + | App: "blog", | |
| 45 | + | FormerRevision: "rev-old", | |
| 46 | + | ResultingRevision: "rev-new", | |
| 47 | + | Pending: true, | |
| 48 | + | Diff: AppDiffSection{Added: []string{"service.web"}, Removed: []string{}, Changed: []string{}}, | |
| 49 | + | Intent: "apply-abc", | |
| 50 | + | } | |
| 51 | + | keys := dtoJSONKeys(t, resp) | |
| 52 | + | for _, key := range []string{"app", "former_revision", "resulting_revision", "pending", "diff", "intent"} { | |
| 53 | + | assert.Contains(t, keys, key) | |
| 54 | + | } | |
| 55 | + | diff := keys["diff"].(map[string]any) | |
| 56 | + | for _, key := range []string{"added", "removed", "changed"} { | |
| 57 | + | assert.Contains(t, diff, key) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestAppDeployResponseKeys(t *testing.T) { | |
| 62 | + | resp := AppDeployResponse{ | |
| 63 | + | Op: "op-abc", | |
| 64 | + | App: "blog", | |
| 65 | + | Revision: "rev-new", | |
| 66 | + | Status: AppStatusRunning, | |
| 67 | + | Outcome: "success", | |
| 68 | + | Services: map[string]AppServiceResultDTO{ | |
| 69 | + | "web": {Result: "deployed", EffectiveRevision: "rev-new"}, | |
| 70 | + | }, | |
| 71 | + | Steps: []AppStepDTO{{ID: "service.web.replace", State: "succeeded"}}, | |
| 72 | + | CleanupWarnings: []AppCleanupWarningDTO{ | |
| 73 | + | {Service: "web", Leftover: "ctr-old", Detail: "retire failed after publish"}, | |
| 74 | + | }, | |
| 75 | + | Effective: &AppEffectiveDTO{Converged: true, Services: map[string]string{"web": "rev-new"}}, | |
| 76 | + | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | |
| 77 | + | } | |
| 78 | + | keys := dtoJSONKeys(t, resp) | |
| 79 | + | for _, key := range []string{"op", "app", "revision", "status", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} { | |
| 80 | + | assert.Contains(t, keys, key) | |
| 81 | + | } | |
| 82 | + | svc := keys["services"].(map[string]any)["web"].(map[string]any) | |
| 83 | + | for _, key := range []string{"result", "effective_revision", "restart_unsafe"} { | |
| 84 | + | assert.Contains(t, svc, key) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestAppShowResponseKeys(t *testing.T) { | |
| 89 | + | resp := AppShowResponse{ | |
| 90 | + | App: "blog", | |
| 91 | + | Desired: AppDesiredDTO{Revision: "rev-new", Status: "pending", Pending: true}, | |
| 92 | + | Active: AppActiveDTO{Converged: false, Services: map[string]AppActiveServiceDTO{ | |
| 93 | + | "web": {EffectiveRevision: "rev-old", Digest: "sha256:abc", Container: "ctr-old", RestartUnsafe: true}, | |
| 94 | + | }}, | |
| 95 | + | Intent: AppIntentDTO{Stopped: false}, | |
| 96 | + | LastOp: &AppLastOpDTO{Op: "op-abc", Outcome: "success"}, | |
| 97 | + | } | |
| 98 | + | keys := dtoJSONKeys(t, resp) | |
| 99 | + | for _, key := range []string{"app", "desired", "active", "intent", "retained", "last_op"} { | |
| 100 | + | assert.Contains(t, keys, key) | |
| 101 | + | } | |
| 102 | + | web := keys["active"].(map[string]any)["services"].(map[string]any)["web"].(map[string]any) | |
| 103 | + | for _, key := range []string{"effective_revision", "digest", "container", "restart_unsafe"} { | |
| 104 | + | assert.Contains(t, web, key) | |
| 105 | + | } | |
| 106 | + | assert.NotContains(t, web, "env", "active services must not inline env values") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // TestAppResponsesCarryNoSecretValues marshals representative responses and | |
| 110 | + | // proves a sentinel secret value appears nowhere. The request that carries | |
| 111 | + | // values (AppSecretSetRequest) is asserted as the single exception. | |
| 112 | + | func TestAppResponsesCarryNoSecretValues(t *testing.T) { | |
| 113 | + | const sentinel = "s3cr3t-value-sentinel" | |
| 114 | + | ||
| 115 | + | responses := []any{ | |
| 116 | + | AppApplyResponse{App: "blog", ResultingRevision: "rev-x", Intent: "apply-x"}, | |
| 117 | + | AppDeployResponse{ | |
| 118 | + | Op: "op-x", App: "blog", Revision: "rev-x", Outcome: "failed", | |
| 119 | + | Services: map[string]AppServiceResultDTO{ | |
| 120 | + | "web": {Result: "failed", EffectiveRevision: "rev-x", Error: "boom"}, | |
| 121 | + | }, | |
| 122 | + | Effective: &AppEffectiveDTO{Services: map[string]string{"web": "rev-x"}}, | |
| 123 | + | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | |
| 124 | + | }, | |
| 125 | + | AppShowResponse{ | |
| 126 | + | App: "blog", | |
| 127 | + | Desired: AppDesiredDTO{Revision: "rev-x", Status: "active"}, | |
| 128 | + | Active: AppActiveDTO{Services: map[string]AppActiveServiceDTO{ | |
| 129 | + | "web": {EffectiveRevision: "rev-x", Digest: "sha256:x", Container: "ctr-x"}, | |
| 130 | + | }}, | |
| 131 | + | }, | |
| 132 | + | AppSummaryDTO{App: "blog", Desired: "rev-x", Active: "rev-x"}, | |
| 133 | + | AppDiffResponse{App: "blog", Diff: AppDiffSection{Changed: []string{"secret.db/password"}}}, | |
| 134 | + | } | |
| 135 | + | for i, resp := range responses { | |
| 136 | + | raw, err := json.Marshal(resp) | |
| 137 | + | require.NoError(t, err, "response %d", i) | |
| 138 | + | body := string(raw) | |
| 139 | + | assert.NotContains(t, body, sentinel, "response %d leaks a secret value", i) | |
| 140 | + | assert.NotContains(t, strings.ToLower(body), `"value"`, "response %d has a value field", i) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // The one intentional exception: the set-secret request transports values. | |
| 144 | + | setReq := AppSecretSetRequest{Service: "web", Secrets: map[string]string{"password": sentinel}} | |
| 145 | + | raw, err := json.Marshal(setReq) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | assert.Contains(t, string(raw), sentinel) | |
| 148 | + | } | |
D internal/adapters/dto/admin_attachments.go +0 -29
| @@ -1,29 +0,0 @@ | |||
| 1 | - | // Package dto provides shared data transfer objects for API responses. | |
| 2 | - | package dto | |
| 3 | - | ||
| 4 | - | // AttachmentsConfigResponse represents all configured attachments. | |
| 5 | - | type AttachmentsConfigResponse struct { | |
| 6 | - | Attachments map[string][]string `json:"attachments"` | |
| 7 | - | } | |
| 8 | - | ||
| 9 | - | // AttachmentConfigResponse represents attachments for a specific target. | |
| 10 | - | type AttachmentConfigResponse struct { | |
| 11 | - | Target string `json:"target"` | |
| 12 | - | Images []string `json:"images"` | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // AttachmentTargetsByImageResponse represents attachment targets for a specific image. | |
| 16 | - | type AttachmentTargetsByImageResponse struct { | |
| 17 | - | Image string `json:"image"` | |
| 18 | - | Targets []string `json:"targets"` | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // AttachmentAddRequest represents a request to add an attachment. | |
| 22 | - | type AttachmentAddRequest struct { | |
| 23 | - | Image string `json:"image"` | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // AttachmentStatusResponse represents attachment operation result. | |
| 27 | - | type AttachmentStatusResponse struct { | |
| 28 | - | Status string `json:"status"` | |
| 29 | - | } | |
D internal/adapters/dto/admin_autoroute.go +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | package dto | |
| 2 | - | ||
| 3 | - | // AutoRouteAllowedDomainsResponse represents the list of allowed domains. | |
| 4 | - | type AutoRouteAllowedDomainsResponse struct { | |
| 5 | - | Domains []string `json:"domains"` | |
| 6 | - | } | |
| 7 | - | ||
| 8 | - | // AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern. | |
| 9 | - | type AutoRouteAllowedDomainRequest struct { | |
| 10 | - | Pattern string `json:"pattern"` | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | // AutoRouteStatusResponse represents a status response for auto-route operations. | |
| 14 | - | type AutoRouteStatusResponse struct { | |
| 15 | - | Status string `json:"status"` | |
| 16 | - | } | |
M internal/adapters/dto/admin_backups.go +30 -39
| @@ -2,11 +2,13 @@ package dto | |||
| 2 | 2 | ||
| 3 | 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 | 7 | type BackupJob struct { | |
| 7 | 8 | ID string `json:"id"` | |
| 8 | - | Domain string `json:"domain"` | |
| 9 | - | DBName string `json:"db_name"` | |
| 9 | + | App string `json:"app"` | |
| 10 | + | Service string `json:"service,omitempty"` | |
| 11 | + | Database string `json:"database,omitempty"` | |
| 10 | 12 | Schedule string `json:"schedule,omitempty"` | |
| 11 | 13 | Type string `json:"type"` | |
| 12 | 14 | Status string `json:"status"` | |
| @@ -21,9 +23,12 @@ type BackupsResponse struct { | |||
| 21 | 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 | 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 | 34 | // BackupRunResponse is returned after triggering a backup. | |
| @@ -32,38 +37,23 @@ type BackupRunResponse struct { | |||
| 32 | 37 | Backup *BackupJob `json:"backup,omitempty"` | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | - | // DatabaseInfo represents a detected database attachment. | |
| 36 | - | type DatabaseInfo struct { | |
| 37 | - | Type string `json:"type"` | |
| 38 | - | Name string `json:"name"` | |
| 39 | - | Version string `json:"version,omitempty"` | |
| 40 | - | Host string `json:"host"` | |
| 41 | - | Port int `json:"port"` | |
| 42 | - | ContainerID string `json:"container_id"` | |
| 43 | - | ImageName string `json:"image_name"` | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // BackupDetectResponse is returned by detect endpoints. | |
| 47 | - | type BackupDetectResponse struct { | |
| 48 | - | Databases []DatabaseInfo `json:"databases"` | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // VolumeBackupJob represents volume backup metadata in admin API responses. | |
| 40 | + | // VolumeBackupJob represents volume backup metadata in admin API | |
| 41 | + | // responses. App, service, and the declared volume name are the identity. | |
| 52 | 42 | type VolumeBackupJob struct { | |
| 53 | - | ID string `json:"id"` | |
| 54 | - | Domain string `json:"domain"` | |
| 55 | - | ContainerName string `json:"container_name,omitempty"` | |
| 56 | - | ContainerID string `json:"container_id,omitempty"` | |
| 57 | - | VolumeName string `json:"volume_name"` | |
| 58 | - | MountPath string `json:"mount_path,omitempty"` | |
| 59 | - | Compression string `json:"compression,omitempty"` | |
| 60 | - | Type string `json:"type"` | |
| 61 | - | Status string `json:"status"` | |
| 62 | - | StartedAt *time.Time `json:"started_at,omitempty"` | |
| 63 | - | CompletedAt *time.Time `json:"completed_at,omitempty"` | |
| 64 | - | SizeBytes int64 `json:"size_bytes"` | |
| 65 | - | ArtifactRef string `json:"artifact_ref,omitempty"` | |
| 66 | - | Error string `json:"error,omitempty"` | |
| 43 | + | ID string `json:"id"` | |
| 44 | + | App string `json:"app"` | |
| 45 | + | Service string `json:"service,omitempty"` | |
| 46 | + | VolumeName string `json:"volume"` | |
| 47 | + | RuntimeVolumeName string `json:"runtime_volume,omitempty"` | |
| 48 | + | MountPath string `json:"mount_path,omitempty"` | |
| 49 | + | Compression string `json:"compression,omitempty"` | |
| 50 | + | Type string `json:"type"` | |
| 51 | + | Status string `json:"status"` | |
| 52 | + | StartedAt *time.Time `json:"started_at,omitempty"` | |
| 53 | + | CompletedAt *time.Time `json:"completed_at,omitempty"` | |
| 54 | + | SizeBytes int64 `json:"size_bytes"` | |
| 55 | + | ArtifactRef string `json:"artifact_ref,omitempty"` | |
| 56 | + | Error string `json:"error,omitempty"` | |
| 67 | 57 | } | |
| 68 | 58 | ||
| 69 | 59 | // VolumeBackupsResponse is returned by volume backup listing endpoints. | |
| @@ -71,12 +61,13 @@ type VolumeBackupsResponse struct { | |||
| 71 | 61 | Backups []VolumeBackupJob `json:"backups"` | |
| 72 | 62 | } | |
| 73 | 63 | ||
| 74 | - | // VolumeBackupRunRequest triggers volume backups. | |
| 64 | + | // VolumeBackupRunRequest triggers one declared volume backup. | |
| 75 | 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 | 71 | type VolumeBackupRunResponse struct { | |
| 81 | 72 | Status string `json:"status"` | |
| 82 | 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 | 1 | package dto | |
| 2 | 2 | ||
| 3 | 3 | // ConfigResponse represents server configuration. | |
| 4 | + | // ConfigResponse represents installation configuration. App routes live | |
| 5 | + | // under apps show, never here. | |
| 4 | 6 | type ConfigResponse struct { | |
| 5 | 7 | Server ServerConfig `json:"server"` | |
| 6 | - | AutoRoute AutoRouteConfig `json:"auto_route"` | |
| 7 | 8 | NetworkIsolation NetworkIsolationConfig `json:"network_isolation"` | |
| 8 | 9 | Volumes VolumesConfig `json:"volumes"` | |
| 9 | - | Routes []Route `json:"routes"` | |
| 10 | 10 | ExternalRoutes []ExternalRoute `json:"external_routes"` | |
| 11 | 11 | } | |
| 12 | 12 | ||
| @@ -18,11 +18,6 @@ type ServerConfig struct { | |||
| 18 | 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 | 21 | // NetworkIsolationConfig represents network isolation settings. | |
| 27 | 22 | type NetworkIsolationConfig struct { | |
| 28 | 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 | 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 | 22 | type ImagePruneRequest struct { | |
| 22 | 23 | KeepLast *int `json:"keep_last,omitempty"` | |
| 23 | 24 | PruneDangling *bool `json:"prune_dangling,omitempty"` | |
| 24 | 25 | PruneRegistry *bool `json:"prune_registry,omitempty"` | |
| 26 | + | DryRun *bool `json:"dry_run,omitempty"` | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // RuntimePruneResult represents runtime prune results. | |
| @@ -37,8 +39,10 @@ type RegistryPruneResult struct { | |||
| 37 | 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 | 44 | type ImagePruneResponse struct { | |
| 42 | 45 | Runtime RuntimePruneResult `json:"runtime"` | |
| 43 | 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 | 3 | // StatusResponse represents admin status information. | |
| 4 | 4 | type StatusResponse struct { | |
| 5 | - | Routes int `json:"routes"` | |
| 5 | + | Apps int `json:"apps"` | |
| 6 | 6 | RegistryDomain string `json:"registry_domain"` | |
| 7 | 7 | RegistryPort int `json:"registry_port"` | |
| 8 | 8 | ServerPort int `json:"server_port"` | |
| 9 | - | AutoRoute bool `json:"auto_route"` | |
| 10 | 9 | NetworkIsolation bool `json:"network_isolation"` | |
| 11 | 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 | 22 | // VolumePruneResponse contains the results of a volume prune operation. | |
| 23 | + | // Plan is the same shape for dry runs and executions. | |
| 23 | 24 | type VolumePruneResponse struct { | |
| 24 | - | VolumesRemoved int `json:"volumes_removed"` | |
| 25 | - | SpaceReclaimed int64 `json:"space_reclaimed"` | |
| 26 | - | Volumes []Volume `json:"volumes,omitempty"` | |
| 25 | + | VolumesRemoved int `json:"volumes_removed"` | |
| 26 | + | SpaceReclaimed int64 `json:"space_reclaimed"` | |
| 27 | + | Volumes []Volume `json:"volumes,omitempty"` | |
| 28 | + | Plan PruneSummary `json:"plan"` | |
| 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 | 60 | // newTokenGenerateCmd creates the token generate command. | |
| 61 | 61 | func newTokenGenerateCmd() *cobra.Command { | |
| 62 | 62 | var ( | |
| 63 | - | subject string | |
| 64 | - | scopes string | |
| 65 | - | expiry string | |
| 66 | - | configPath string | |
| 67 | - | repo string | |
| 63 | + | subject string | |
| 64 | + | scopes string | |
| 65 | + | expiry string | |
| 66 | + | cliConfigPath string | |
| 67 | + | repo string | |
| 68 | + | rawOut bool | |
| 69 | + | jsonOut bool | |
| 68 | 70 | ) | |
| 69 | 71 | ||
| 70 | 72 | cmd := &cobra.Command{ | |
| @@ -84,12 +86,13 @@ Registry scopes (for Docker push/pull): | |||
| 84 | 86 | Admin scopes (for remote CLI access): | |
| 85 | 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 | 90 | Actions: read, write, * (all) | |
| 89 | 91 | ||
| 90 | 92 | Examples: | |
| 91 | 93 | admin:*:* Full admin access (recommended for CLI) | |
| 92 | - | admin:routes:read Read-only routes access | |
| 94 | + | admin:apps:read Read-only apps access | |
| 95 | + | admin:apps:write App mutations (apply, deploy, lifecycle) | |
| 93 | 96 | admin:status:read Read-only status access | |
| 94 | 97 | admin:logs:read Read-only log access | |
| 95 | 98 | admin:volumes:read Read-only volume access | |
| @@ -102,8 +105,8 @@ Repository scoping: | |||
| 102 | 105 | Combined examples: | |
| 103 | 106 | --scopes "push,pull" All repos, registry only (default) | |
| 104 | 107 | --scopes "push" --repo myapp Push to myapp only | |
| 105 | - | --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos) | |
| 106 | - | --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo) | |
| 108 | + | --scopes "push,pull,admin:apps:read" Minimum CI scope (all repos) | |
| 109 | + | --scopes "push,admin:apps:read" --repo app Minimum CI scope (specific repo) | |
| 107 | 110 | ||
| 108 | 111 | EXPIRY: | |
| 109 | 112 | ||
| @@ -114,15 +117,18 @@ Supports human-friendly durations: | |||
| 114 | 117 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | |
| 115 | 118 | Use --expiry=0 for a token that never expires (useful for CI).`, | |
| 116 | 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 | 124 | cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)") | |
| 122 | 125 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | |
| 123 | 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 | 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 | 133 | _ = cmd.MarkFlagRequired("subject") | |
| 128 | 134 | ||
| @@ -132,8 +138,8 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||
| 132 | 138 | // newTokenListCmd creates the token list command. | |
| 133 | 139 | func newTokenListCmd() *cobra.Command { | |
| 134 | 140 | var ( | |
| 135 | - | configPath string | |
| 136 | - | jsonOut bool | |
| 141 | + | cliConfigPath string | |
| 142 | + | jsonOut bool | |
| 137 | 143 | ) | |
| 138 | 144 | ||
| 139 | 145 | cmd := &cobra.Command{ | |
| @@ -141,11 +147,11 @@ func newTokenListCmd() *cobra.Command { | |||
| 141 | 147 | Short: "List all authentication tokens", | |
| 142 | 148 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | |
| 143 | 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 | 155 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 150 | 156 | ||
| 151 | 157 | return cmd | |
| @@ -154,8 +160,8 @@ func newTokenListCmd() *cobra.Command { | |||
| 154 | 160 | // newTokenRevokeCmd creates the token revoke command. | |
| 155 | 161 | func newTokenRevokeCmd() *cobra.Command { | |
| 156 | 162 | var ( | |
| 157 | - | configPath string | |
| 158 | - | all bool | |
| 163 | + | cliConfigPath string | |
| 164 | + | all bool | |
| 159 | 165 | ) | |
| 160 | 166 | ||
| 161 | 167 | cmd := &cobra.Command{ | |
| @@ -169,16 +175,16 @@ Examples: | |||
| 169 | 175 | Args: cobra.MaximumNArgs(1), | |
| 170 | 176 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 171 | 177 | if all { | |
| 172 | - | return runTokenRevokeAll(configPath) | |
| 178 | + | return runTokenRevokeAll(cliConfigPath) | |
| 173 | 179 | } | |
| 174 | 180 | if len(args) == 0 { | |
| 175 | 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 | 188 | cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens") | |
| 183 | 189 | ||
| 184 | 190 | return cmd | |
| @@ -188,18 +194,21 @@ Examples: | |||
| 188 | 194 | func newAuthInternalCmd() *cobra.Command { | |
| 189 | 195 | return &cobra.Command{ | |
| 190 | 196 | Use: "internal", | |
| 191 | - | Short: "Show internal registry credentials for manual recovery", | |
| 192 | - | Long: `Display the auto-generated internal registry credentials. | |
| 197 | + | Short: "Show server-local registry recovery credentials", | |
| 198 | + | Long: `Display the server's auto-generated internal registry credentials. | |
| 199 | + | ||
| 200 | + | This sensitive recovery command is available only when Gordon runs locally on | |
| 201 | + | the server. The daemon normally uses these ephemeral credentials automatically | |
| 202 | + | to pull from its registry; remote clients and ordinary pushes use scoped tokens. | |
| 193 | 203 | ||
| 194 | - | These credentials are used by Gordon for pulling images from the local registry | |
| 195 | - | (localhost:5000) during internal deploys. You can use them for manual recovery: | |
| 204 | + | For local recovery only: | |
| 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 only | |
| 200 | - | available while Gordon is running.`, | |
| 209 | + | Credentials are regenerated whenever the Gordon daemon starts.`, | |
| 201 | 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 | 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 | 421 | creds, err := app.GetInternalCredentials() | |
| 407 | 422 | if err != nil { | |
| 408 | 423 | return err | |
| 409 | 424 | } | |
| 410 | 425 | ||
| 411 | - | fmt.Println("Internal Registry Credentials") | |
| 412 | - | fmt.Println("==============================") | |
| 413 | - | fmt.Printf("Username: %s\n", creds.Username) | |
| 414 | - | fmt.Printf("Password: %s\n", creds.Password) | |
| 415 | - | fmt.Println() | |
| 416 | - | fmt.Println("Usage:") | |
| 417 | - | fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password) | |
| 418 | - | ||
| 419 | - | return nil | |
| 426 | + | if err := cliWriteLine(out, "Internal Registry Credentials"); err != nil { | |
| 427 | + | return err | |
| 428 | + | } | |
| 429 | + | if err := cliWriteLine(out, "=============================="); err != nil { | |
| 430 | + | return err | |
| 431 | + | } | |
| 432 | + | if err := cliWritef(out, "Username: %s\n", creds.Username); err != nil { | |
| 433 | + | return err | |
| 434 | + | } | |
| 435 | + | if err := cliWritef(out, "Password: %s\n", creds.Password); err != nil { | |
| 436 | + | return err | |
| 437 | + | } | |
| 438 | + | return cliWriteLine(out, "\nThese credentials are sensitive and intended only for server-local recovery.") | |
| 420 | 439 | } | |
| 421 | 440 | ||
| 422 | 441 | // runTokenGenerate generates a new authentication token. | |
| @@ -463,8 +482,8 @@ func parseAndConvertScopes(scopesStr, repo string) ([]string, error) { | |||
| 463 | 482 | return scopes, nil | |
| 464 | 483 | } | |
| 465 | 484 | ||
| 466 | - | func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error { | |
| 467 | - | cfg, err := loadAuthConfig(configPath) | |
| 485 | + | func runTokenGenerate(subject, scopesStr, expiryStr, cliConfigPath, repo string, out io.Writer, rawOut, jsonOut bool) error { | |
| 486 | + | cfg, err := loadAuthConfig(cliConfigPath) | |
| 468 | 487 | if err != nil { | |
| 469 | 488 | return err | |
| 470 | 489 | } | |
| @@ -497,26 +516,34 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er | |||
| 497 | 516 | return fmt.Errorf("failed to generate token: %w", err) | |
| 498 | 517 | } | |
| 499 | 518 | ||
| 500 | - | fmt.Println("Token generated successfully!") | |
| 501 | - | fmt.Printf("Subject: %s\n", subject) | |
| 502 | - | fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", ")) | |
| 503 | - | if expiry == 0 { | |
| 504 | - | fmt.Println("Expiry: never") | |
| 505 | - | } else { | |
| 506 | - | fmt.Printf("Expiry: %s\n", expiry) | |
| 519 | + | if rawOut { | |
| 520 | + | return cliWriteLine(out, token) | |
| 521 | + | } | |
| 522 | + | if jsonOut { | |
| 523 | + | return writeJSON(out, map[string]any{ | |
| 524 | + | "subject": subject, | |
| 525 | + | "scopes": scopes, | |
| 526 | + | "expiry": expiry.String(), | |
| 527 | + | "token": token, | |
| 528 | + | }) | |
| 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 nil | |
| 531 | + | if err := cliWriteLine(out, "Token generated successfully!"); err != nil { | |
| 532 | + | return err | |
| 533 | + | } | |
| 534 | + | if err := cliWritef(out, "Subject: %s\nScopes: %s\n", subject, strings.Join(scopes, ", ")); err != nil { | |
| 535 | + | return err | |
| 536 | + | } | |
| 537 | + | expiryLabel := expiry.String() | |
| 538 | + | if expiry == 0 { | |
| 539 | + | expiryLabel = "never" | |
| 540 | + | } | |
| 541 | + | return cliWritef(out, "Expiry: %s\n\nToken (use as password with docker login):\n%s\n\nUsage: docker login -u %s -p <token> <registry>\n", expiryLabel, token, subject) | |
| 515 | 542 | } | |
| 516 | 543 | ||
| 517 | 544 | // runTokenList lists all stored tokens. | |
| 518 | - | func runTokenList(configPath string, out io.Writer, jsonOut bool) error { | |
| 519 | - | cfg, err := loadAuthConfig(configPath) | |
| 545 | + | func runTokenList(cliConfigPath string, out io.Writer, jsonOut bool) error { | |
| 546 | + | cfg, err := loadAuthConfig(cliConfigPath) | |
| 520 | 547 | if err != nil { | |
| 521 | 548 | return err | |
| 522 | 549 | } | |
| @@ -579,8 +606,8 @@ func runTokenList(configPath string, out io.Writer, jsonOut bool) error { | |||
| 579 | 606 | } | |
| 580 | 607 | ||
| 581 | 608 | // runTokenRevoke revokes a token by ID. | |
| 582 | - | func runTokenRevoke(tokenID, configPath string) error { | |
| 583 | - | cfg, err := loadAuthConfig(configPath) | |
| 609 | + | func runTokenRevoke(tokenID, cliConfigPath string) error { | |
| 610 | + | cfg, err := loadAuthConfig(cliConfigPath) | |
| 584 | 611 | if err != nil { | |
| 585 | 612 | return err | |
| 586 | 613 | } | |
| @@ -601,8 +628,8 @@ func runTokenRevoke(tokenID, configPath string) error { | |||
| 601 | 628 | } | |
| 602 | 629 | ||
| 603 | 630 | // runTokenRevokeAll revokes all tokens. | |
| 604 | - | func runTokenRevokeAll(configPath string) error { | |
| 605 | - | cfg, err := loadAuthConfig(configPath) | |
| 631 | + | func runTokenRevokeAll(cliConfigPath string) error { | |
| 632 | + | cfg, err := loadAuthConfig(cliConfigPath) | |
| 606 | 633 | if err != nil { | |
| 607 | 634 | return err | |
| 608 | 635 | } | |
| @@ -635,14 +662,14 @@ type cliConfig struct { | |||
| 635 | 662 | } | |
| 636 | 663 | ||
| 637 | 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 | 666 | v := viper.New() | |
| 640 | 667 | ||
| 641 | 668 | // Set defaults | |
| 642 | 669 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | |
| 643 | 670 | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 644 | 671 | ||
| 645 | - | app.ConfigureViper(v, configPath) | |
| 672 | + | app.ConfigureViper(v, cliConfigPath) | |
| 646 | 673 | ||
| 647 | 674 | if err := v.ReadInConfig(); err != nil { | |
| 648 | 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 | 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 | 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 | 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 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | - | "os" | |
| 8 | 7 | "text/tabwriter" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| @@ -14,54 +13,35 @@ import ( | |||
| 14 | 13 | "github.com/bnema/gordon/pkg/bytesize" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | - | var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) { | |
| 18 | - | if domainName != "" { | |
| 19 | - | return resolveControlPlaneForRouteDomain(ctx, domainName) | |
| 20 | - | } | |
| 21 | - | return resolveControlPlane(configPath) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // newBackupCmd creates the backup command group. | |
| 16 | + | // newBackupCmd creates the backup command group. Backups are identified by | |
| 17 | + | // app, service, and the declared database or volume: a domain is never an | |
| 18 | + | // identity. | |
| 25 | 19 | func newBackupCmd() *cobra.Command { | |
| 26 | 20 | cmd := &cobra.Command{ | |
| 27 | 21 | Use: "backups", | |
| 28 | 22 | Aliases: []string{"backup"}, | |
| 29 | - | Short: "Manage backups", | |
| 30 | - | Long: `Manage backups. | |
| 23 | + | Short: "Manage app backups", | |
| 24 | + | Long: `Manage app backups. | |
| 31 | 25 | ||
| 32 | - | Runs locally via in-process services by default, or against a remote Gordon | |
| 33 | - | instance when --remote targeting is configured.`, | |
| 26 | + | Backup targets come from the app manifest: a service declares its databases | |
| 27 | + | and volumes, and which of them are backed up. Runs locally through the daemon's | |
| 28 | + | authenticated Unix socket, or against a remote Gordon instance when --remote | |
| 29 | + | targeting is configured.`, | |
| 34 | 30 | } | |
| 35 | 31 | ||
| 36 | - | cmd.AddCommand(newBackupDatabasesCmd()) | |
| 37 | - | cmd.AddCommand(newBackupVolumesCmd()) | |
| 38 | - | ||
| 39 | - | // Compatibility aliases for the original database backup command shape. | |
| 40 | 32 | cmd.AddCommand(newBackupListCmd()) | |
| 41 | 33 | cmd.AddCommand(newBackupRunCmd()) | |
| 42 | - | cmd.AddCommand(newBackupDetectCmd()) | |
| 43 | 34 | cmd.AddCommand(newBackupStatusCmd()) | |
| 35 | + | cmd.AddCommand(newBackupVolumeCmd()) | |
| 44 | 36 | ||
| 45 | 37 | return cmd | |
| 46 | 38 | } | |
| 47 | 39 | ||
| 48 | - | func newBackupDatabasesCmd() *cobra.Command { | |
| 49 | - | cmd := &cobra.Command{ | |
| 50 | - | Use: "databases", | |
| 51 | - | Aliases: []string{"database", "db"}, | |
| 52 | - | Short: "Manage database backups", | |
| 53 | - | } | |
| 54 | - | cmd.AddCommand(newBackupListCmd()) | |
| 55 | - | cmd.AddCommand(newBackupRunCmd()) | |
| 56 | - | cmd.AddCommand(newBackupDetectCmd()) | |
| 57 | - | cmd.AddCommand(newBackupStatusCmd()) | |
| 58 | - | return cmd | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | func newBackupVolumesCmd() *cobra.Command { | |
| 40 | + | // newBackupVolumeCmd creates the `backup volume` group. | |
| 41 | + | func newBackupVolumeCmd() *cobra.Command { | |
| 62 | 42 | cmd := &cobra.Command{ | |
| 63 | - | Use: "volumes", | |
| 64 | - | Short: "Manage volume backups", | |
| 43 | + | Use: "volume", | |
| 44 | + | Short: "Manage app volume backups", | |
| 65 | 45 | } | |
| 66 | 46 | cmd.AddCommand(newVolumeBackupListCmd()) | |
| 67 | 47 | cmd.AddCommand(newVolumeBackupRunCmd()) | |
| @@ -73,44 +53,36 @@ func newBackupListCmd() *cobra.Command { | |||
| 73 | 53 | var jsonOut bool | |
| 74 | 54 | ||
| 75 | 55 | cmd := &cobra.Command{ | |
| 76 | - | Use: "list [domain]", | |
| 77 | - | Short: "List backups", | |
| 78 | - | Long: cliRenderMuted("List backups for all domains or a specific domain."), | |
| 56 | + | Use: "list [APP]", | |
| 57 | + | Short: "List database backups", | |
| 58 | + | Long: cliRenderMuted("List stored backups for all apps, or for one app."), | |
| 79 | 59 | Args: cobra.MaximumNArgs(1), | |
| 80 | 60 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 81 | - | domainName := "" | |
| 61 | + | app := "" | |
| 82 | 62 | if len(args) == 1 { | |
| 83 | - | domainName = args[0] | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | var ( | |
| 87 | - | handle *controlPlaneHandle | |
| 88 | - | err error | |
| 89 | - | ) | |
| 90 | - | if domainName != "" { | |
| 91 | - | handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName) | |
| 92 | - | } else { | |
| 93 | - | handle, err = resolveControlPlane(configPath) | |
| 63 | + | app = args[0] | |
| 94 | 64 | } | |
| 65 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 95 | 66 | if err != nil { | |
| 96 | 67 | return err | |
| 97 | 68 | } | |
| 98 | 69 | defer handle.close() | |
| 99 | - | ||
| 100 | - | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) | |
| 101 | - | if err != nil { | |
| 102 | - | return fmt.Errorf("failed to list backups: %w", err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) | |
| 70 | + | return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut) | |
| 106 | 71 | }, | |
| 107 | 72 | } | |
| 108 | 73 | ||
| 109 | 74 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 110 | - | ||
| 111 | 75 | 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 | 86 | func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |
| 115 | 87 | if len(jobs) == 0 { | |
| 116 | 88 | if jsonOut { | |
| @@ -127,106 +99,192 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |||
| 127 | 99 | return err | |
| 128 | 100 | } | |
| 129 | 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 | 103 | return err | |
| 132 | 104 | } | |
| 133 | - | ||
| 134 | 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 | 108 | return err | |
| 137 | 109 | } | |
| 138 | 110 | } | |
| 139 | 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 | 117 | var jsonOut bool | |
| 144 | 118 | ||
| 145 | 119 | cmd := &cobra.Command{ | |
| 146 | - | Use: "list [domain]", | |
| 147 | - | Short: "List volume backups", | |
| 148 | - | Args: cobra.MaximumNArgs(1), | |
| 120 | + | Use: "run APP", | |
| 121 | + | Short: "Run a database backup now", | |
| 122 | + | Long: cliRenderMuted(`Run the declared database backup of one app service. | |
| 123 | + | ||
| 124 | + | --service and --database select the target. Omitting a selector is only | |
| 125 | + | allowed when exactly one compatible target exists.`), | |
| 126 | + | Args: cobra.ExactArgs(1), | |
| 149 | 127 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 150 | - | domainName := "" | |
| 151 | - | if len(args) == 1 { | |
| 152 | - | domainName = args[0] | |
| 153 | - | } | |
| 154 | - | handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName) | |
| 128 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 155 | 129 | if err != nil { | |
| 156 | 130 | return err | |
| 157 | 131 | } | |
| 158 | 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 | 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 | 172 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 168 | 173 | return cmd | |
| 169 | 174 | } | |
| 170 | 175 | ||
| 171 | - | func newVolumeBackupRunCmd() *cobra.Command { | |
| 172 | - | var volumeName string | |
| 176 | + | func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error { | |
| 177 | + | jobs, err := plane.BackupStatus(ctx) | |
| 178 | + | if err != nil { | |
| 179 | + | return fmt.Errorf("failed to get backup status: %w", err) | |
| 180 | + | } | |
| 181 | + | return printBackupJobs(out, jobs, jsonOut) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func newVolumeBackupListCmd() *cobra.Command { | |
| 173 | 185 | var jsonOut bool | |
| 174 | 186 | ||
| 175 | 187 | cmd := &cobra.Command{ | |
| 176 | - | Use: "run [domain]", | |
| 177 | - | Short: "Run volume backups now", | |
| 188 | + | Use: "list [APP]", | |
| 189 | + | Short: "List volume backups", | |
| 178 | 190 | Args: cobra.MaximumNArgs(1), | |
| 179 | 191 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 180 | - | domainName := "" | |
| 192 | + | app := "" | |
| 181 | 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 | 197 | if err != nil { | |
| 186 | 198 | return err | |
| 187 | 199 | } | |
| 188 | 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 | 231 | if err != nil { | |
| 192 | - | if result != nil && len(result.Backups) > 0 { | |
| 193 | - | if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil { | |
| 194 | - | return printErr | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | return fmt.Errorf("failed to run volume backups: %w", err) | |
| 232 | + | return err | |
| 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 | 240 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 204 | 241 | 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 | 262 | func newVolumeBackupStatusCmd() *cobra.Command { | |
| 208 | 263 | var jsonOut bool | |
| 209 | 264 | cmd := &cobra.Command{ | |
| 210 | 265 | Use: "status", | |
| 211 | 266 | Short: "Show volume backup status", | |
| 212 | 267 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 213 | - | handle, err := backupResolveControlPlane(cmd.Context(), configPath, "") | |
| 268 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 214 | 269 | if err != nil { | |
| 215 | 270 | return err | |
| 216 | 271 | } | |
| 217 | 272 | defer handle.close() | |
| 218 | - | ||
| 219 | - | jobs, err := handle.plane.VolumeBackupStatus(cmd.Context()) | |
| 220 | - | if err != nil { | |
| 221 | - | return fmt.Errorf("failed to get volume backup status: %w", err) | |
| 222 | - | } | |
| 223 | - | return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) | |
| 273 | + | return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut) | |
| 224 | 274 | }, | |
| 225 | 275 | } | |
| 226 | 276 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 227 | 277 | 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 | 288 | func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error { | |
| 231 | 289 | if len(jobs) == 0 { | |
| 232 | 290 | if jsonOut { | |
| @@ -241,150 +299,19 @@ func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bo | |||
| 241 | 299 | return err | |
| 242 | 300 | } | |
| 243 | 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 | 303 | return err | |
| 246 | 304 | } | |
| 247 | 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 | 309 | return err | |
| 250 | 310 | } | |
| 251 | 311 | } | |
| 252 | 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 | 315 | func formatBackupTime(t *time.Time) string { | |
| 389 | 316 | if t == nil { | |
| 390 | 317 | return "" | |
M internal/adapters/in/cli/backup_volume_test.go +13 -63
| @@ -13,86 +13,36 @@ import ( | |||
| 13 | 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 | 19 | t.Helper() | |
| 18 | - | old := backupResolveControlPlane | |
| 19 | - | backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) { | |
| 20 | - | return &controlPlaneHandle{plane: plane}, nil | |
| 21 | - | } | |
| 22 | - | t.Cleanup(func() { backupResolveControlPlane = old }) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) { | |
| 26 | - | plane := climocks.NewMockControlPlane(t) | |
| 27 | - | old := backupResolveControlPlane | |
| 28 | - | var gotDomain string | |
| 29 | - | backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) { | |
| 30 | - | gotDomain = domainName | |
| 31 | - | return &controlPlaneHandle{plane: plane}, nil | |
| 32 | - | } | |
| 33 | - | t.Cleanup(func() { backupResolveControlPlane = old }) | |
| 34 | - | ||
| 35 | - | plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil) | |
| 36 | - | ||
| 37 | - | cmd := newVolumeBackupRunCmd() | |
| 38 | - | cmd.SetArgs([]string{"app.example.com"}) | |
| 39 | - | ||
| 40 | - | require.NoError(t, cmd.ExecuteContext(context.Background())) | |
| 41 | - | require.Equal(t, "app.example.com", gotDomain) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func TestVolumeBackupRun_JSONFlag(t *testing.T) { | |
| 45 | - | plane := climocks.NewMockControlPlane(t) | |
| 46 | - | withBackupControlPlane(t, plane) | |
| 47 | - | ||
| 48 | - | plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{ | |
| 49 | - | Status: "ok", | |
| 50 | - | Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}}, | |
| 51 | - | }, nil) | |
| 52 | - | ||
| 53 | - | cmd := newVolumeBackupRunCmd() | |
| 54 | - | var out bytes.Buffer | |
| 55 | - | cmd.SetOut(&out) | |
| 56 | - | cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"}) | |
| 57 | - | ||
| 58 | - | require.NoError(t, cmd.ExecuteContext(context.Background())) | |
| 59 | - | require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String()) | |
| 20 | + | return climocks.NewMockControlPlane(t) | |
| 60 | 21 | } | |
| 61 | 22 | ||
| 62 | 23 | func TestVolumeBackupStatus_JSONFlag(t *testing.T) { | |
| 63 | - | plane := climocks.NewMockControlPlane(t) | |
| 64 | - | withBackupControlPlane(t, plane) | |
| 24 | + | plane := backupRunPlane(t) | |
| 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 | 28 | var out bytes.Buffer | |
| 70 | - | cmd.SetOut(&out) | |
| 71 | - | cmd.SetArgs([]string{"--json"}) | |
| 72 | - | ||
| 73 | - | require.NoError(t, cmd.ExecuteContext(context.Background())) | |
| 74 | - | require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String()) | |
| 29 | + | require.NoError(t, runVolumeBackupStatus(context.Background(), plane, &out, true)) | |
| 30 | + | require.JSONEq(t, `[{"id":"","app":"shop","service":"api","volume":"data","type":"","status":"running","size_bytes":0}]`, out.String()) | |
| 75 | 31 | } | |
| 76 | 32 | ||
| 77 | 33 | func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) { | |
| 78 | - | plane := climocks.NewMockControlPlane(t) | |
| 79 | - | withBackupControlPlane(t, plane) | |
| 34 | + | plane := backupRunPlane(t) | |
| 80 | 35 | ||
| 81 | 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 | 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 | 40 | Error: runErr.Error(), | |
| 86 | 41 | }, runErr) | |
| 87 | 42 | ||
| 88 | - | cmd := newVolumeBackupRunCmd() | |
| 89 | - | cmd.SilenceUsage = true | |
| 90 | 43 | var out bytes.Buffer | |
| 91 | - | cmd.SetOut(&out) | |
| 92 | - | cmd.SetArgs([]string{"app.example.com", "--json"}) | |
| 93 | - | ||
| 94 | - | err := cmd.ExecuteContext(context.Background()) | |
| 44 | + | err := runVolumeBackupRun(context.Background(), plane, "shop", "api", "data", &out, true) | |
| 95 | 45 | require.Error(t, err) | |
| 96 | 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 | 98 | func resolveCADataDir() (string, error) { | |
| 99 | - | local, err := GetLocalServices(configPath) | |
| 99 | + | local, err := GetLocalServices(cliConfigPath) | |
| 100 | 100 | if err != nil { | |
| 101 | 101 | return "", err | |
| 102 | 102 | } | |
M internal/adapters/in/cli/config.go +5 -41
| @@ -17,7 +17,7 @@ func newConfigCmd() *cobra.Command { | |||
| 17 | 17 | Short: "Inspect Gordon configuration", | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | cmd.AddCommand(newConfigShowCmd()) | |
| 20 | + | cmd.AddCommand(newConfigShowCmd(), newConfigValidateCmd()) | |
| 21 | 21 | ||
| 22 | 22 | return cmd | |
| 23 | 23 | } | |
| @@ -32,12 +32,12 @@ func newConfigShowCmd() *cobra.Command { | |||
| 32 | 32 | auto-route, network isolation, routes, and external routes. | |
| 33 | 33 | ||
| 34 | 34 | Examples: | |
| 35 | - | gordon config show | |
| 36 | - | gordon config show --json | |
| 37 | - | gordon config show --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 35 | + | gordon daemon config show | |
| 36 | + | gordon daemon config show --json | |
| 37 | + | gordon daemon config show --remote prod`, | |
| 38 | 38 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 39 | 39 | ctx := cmd.Context() | |
| 40 | - | handle, err := resolveControlPlane(configPath) | |
| 40 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 41 | 41 | if err != nil { | |
| 42 | 42 | return err | |
| 43 | 43 | } | |
| @@ -72,9 +72,6 @@ func renderConfigTable(out io.Writer, config *remote.Config) error { | |||
| 72 | 72 | if err := renderConfigSummary(out, config); err != nil { | |
| 73 | 73 | return err | |
| 74 | 74 | } | |
| 75 | - | if err := renderConfigRoutes(out, config); err != nil { | |
| 76 | - | return err | |
| 77 | - | } | |
| 78 | 75 | return renderConfigExternalRoutes(out, config) | |
| 79 | 76 | } | |
| 80 | 77 | ||
| @@ -99,9 +96,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error { | |||
| 99 | 96 | 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 | 99 | if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil { | |
| 106 | 100 | return err | |
| 107 | 101 | } | |
| @@ -113,36 +107,6 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error { | |||
| 113 | 107 | 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 | 110 | func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | |
| 147 | 111 | if err := cliWriteLine(out, ""); err != nil { | |
| 148 | 112 | 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 | 11 | // ControlPlane defines command operations available to CLI execution paths. | |
| 12 | - | // | |
| 13 | - | // Remote implementations call admin HTTP APIs. | |
| 14 | - | // Local implementations call services directly in-process. | |
| 12 | + | // *remote.Client implements it for both the explicit remote and the | |
| 13 | + | // owner-only local admin socket; tests substitute the generated mock. | |
| 15 | 14 | type ControlPlane interface { | |
| 16 | - | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | |
| 17 | - | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | |
| 18 | - | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | |
| 19 | - | FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) | |
| 20 | - | AddRoute(ctx context.Context, route domain.Route) error | |
| 21 | - | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 22 | - | RemoveRoute(ctx context.Context, routeDomain string) error | |
| 23 | - | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |
| 24 | - | ||
| 25 | - | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 26 | - | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| 27 | - | DeleteSecret(ctx context.Context, secretDomain, key string) error | |
| 28 | - | SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error | |
| 29 | - | DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error | |
| 30 | - | ||
| 31 | - | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |
| 32 | - | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 33 | - | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |
| 34 | - | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |
| 35 | - | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |
| 36 | - | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 37 | - | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 38 | - | GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) | |
| 39 | - | AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 40 | - | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 15 | + | // App lifecycle and reads. App mutations are daemon-owned for both | |
| 16 | + | // the explicit remote and the owner-only local admin socket. | |
| 17 | + | ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) | |
| 18 | + | ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) | |
| 19 | + | ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) | |
| 20 | + | DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) | |
| 21 | + | DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) | |
| 22 | + | StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 23 | + | StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 24 | + | RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) | |
| 25 | + | RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 26 | + | OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) | |
| 27 | + | ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error) | |
| 28 | + | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | |
| 29 | + | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | |
| 41 | 30 | ||
| 42 | 31 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 43 | 32 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| @@ -45,24 +34,20 @@ type ControlPlane interface { | |||
| 45 | 34 | Reload(ctx context.Context) error | |
| 46 | 35 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 47 | 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 | 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 | 40 | BackupStatus(ctx context.Context) ([]dto.BackupJob, error) | |
| 55 | - | RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) | |
| 56 | - | DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) | |
| 57 | - | ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) | |
| 41 | + | RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) | |
| 42 | + | ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) | |
| 58 | 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 | 46 | GetProcessLogs(ctx context.Context, lines int) ([]string, error) | |
| 62 | - | GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) | |
| 63 | 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 | 49 | ListVolumes(ctx context.Context) ([]dto.Volume, error) | |
| 67 | 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 | 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 | 18 | func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) { | |
| 28 | 19 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 29 | 20 | w.Header().Set("Content-Type", "application/json") | |
| 30 | 21 | w.WriteHeader(http.StatusPartialContent) | |
| 31 | 22 | require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{ | |
| 32 | 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 | 25 | Error: "one volume failed", | |
| 35 | 26 | })) | |
| 36 | 27 | })) | |
| 37 | 28 | t.Cleanup(server.Close) | |
| 38 | 29 | ||
| 39 | - | cp := NewRemoteControlPlane(remote.NewClient(server.URL)) | |
| 40 | - | result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "") | |
| 30 | + | cp := remote.NewClient(server.URL) | |
| 31 | + | result, err := cp.RunVolumeBackups(context.Background(), "shop", "api", "data") | |
| 41 | 32 | ||
| 42 | 33 | require.Error(t, err) | |
| 43 | - | assert.Contains(t, err.Error(), "run volume backups") | |
| 34 | + | assert.Contains(t, err.Error(), "one volume failed") | |
| 44 | 35 | require.NotNil(t, result) | |
| 45 | 36 | assert.Equal(t, "partial", result.Status) | |
| 46 | 37 | require.Len(t, result.Backups, 1) | |
| 47 | 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 | 42 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 52 | 43 | http.Error(w, "boom", http.StatusBadGateway) | |
| 53 | 44 | })) | |
| 54 | 45 | t.Cleanup(server.Close) | |
| 55 | 46 | ||
| 56 | - | cp := NewRemoteControlPlane(remote.NewClient(server.URL)) | |
| 47 | + | cp := remote.NewClient(server.URL) | |
| 57 | 48 | _, err := cp.VolumeBackupStatus(context.Background()) | |
| 58 | 49 | ||
| 59 | 50 | require.Error(t, err) | |
| 60 | - | assert.Contains(t, err.Error(), "get volume backup status") | |
| 61 | 51 | var httpErr *remote.HTTPError | |
| 62 | 52 | require.True(t, errors.As(err, &httpErr)) | |
| 63 | 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 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 8 | - | "github.com/bnema/gordon/internal/app" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | 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 | 29 | client, isRemote, err := GetRemoteClient() | |
| 29 | - | if err != nil { | |
| 30 | - | return nil, err | |
| 30 | + | if err != nil || isRemote { | |
| 31 | + | return client, isRemote, err | |
| 31 | 32 | } | |
| 32 | - | if isRemote { | |
| 33 | - | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil | |
| 33 | + | ||
| 34 | + | client, err = newLocalControlPlaneClient() | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, false, fmt.Errorf("local control plane unavailable: %w", err) | |
| 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 | 49 | func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle { | |
| 40 | 50 | client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...) | |
| 41 | - | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |
| 45 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 46 | - | return inferRemoteForRouteDomain(ctx, routeDomain) | |
| 47 | - | }) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |
| 51 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 52 | - | return inferRemoteForRouteCleanupDomain(ctx, routeDomain) | |
| 53 | - | }) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) { | |
| 57 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 58 | - | return inferRemoteForAttachmentTarget(ctx, target) | |
| 59 | - | }) | |
| 51 | + | return &controlPlaneHandle{plane: client, isRemote: true} | |
| 60 | 52 | } | |
| 61 | 53 | ||
| 62 | 54 | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | |
| @@ -73,17 +65,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co | |||
| 73 | 65 | if resolved != nil { | |
| 74 | 66 | return newRemoteControlPlaneHandle(resolved), nil | |
| 75 | 67 | } | |
| 76 | - | return resolveControlPlane(configPath) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 80 | - | kernel, err := app.NewKernelQuiet(configPath) | |
| 81 | - | if err != nil { | |
| 82 | - | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | return &controlPlaneHandle{ | |
| 86 | - | plane: NewLocalControlPlane(kernel), | |
| 87 | - | closeFn: kernel.Close, | |
| 88 | - | }, nil | |
| 68 | + | return resolveControlPlane(cliConfigPath) | |
| 89 | 69 | } | |
M internal/adapters/in/cli/controlplane_resolver_test.go +94 -70
| @@ -1,110 +1,134 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "net/http" | |
| 4 | 8 | "os" | |
| 5 | 9 | "path/filepath" | |
| 6 | 10 | "testing" | |
| 11 | + | "time" | |
| 7 | 12 | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 8 | 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) { | |
| 12 | - | tmpDir := t.TempDir() | |
| 13 | - | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 20 | + | func isolateControlPlaneResolver(t *testing.T) { | |
| 21 | + | t.Helper() | |
| 22 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg")) | |
| 14 | 23 | t.Setenv("GORDON_REMOTE", "") | |
| 15 | 24 | t.Setenv("GORDON_TOKEN", "") | |
| 16 | 25 | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |
| 26 | + | originalFactory := newLocalControlPlaneClient | |
| 17 | 27 | t.Cleanup(func() { | |
| 18 | 28 | remoteFlag = originalRemoteFlag | |
| 19 | 29 | tokenFlag = originalTokenFlag | |
| 20 | 30 | insecureTLSFlag = originalInsecureTLSFlag | |
| 31 | + | newLocalControlPlaneClient = originalFactory | |
| 21 | 32 | }) | |
| 22 | - | ||
| 23 | 33 | remoteFlag = "" | |
| 24 | 34 | tokenFlag = "" | |
| 25 | 35 | insecureTLSFlag = false | |
| 36 | + | } | |
| 26 | 37 | ||
| 27 | - | configPath := filepath.Join(tmpDir, "gordon.toml") | |
| 28 | - | err := os.WriteFile(configPath, []byte(`[server] | |
| 29 | - | gordon_domain = "gordon.local" | |
| 30 | - | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |
| 31 | - | ||
| 32 | - | [auth] | |
| 33 | - | enabled = true | |
| 34 | - | secrets_backend = "unsafe" | |
| 35 | - | `), 0o600) | |
| 38 | + | func localControlPlaneTestClient(t *testing.T, handler http.Handler) *remote.Client { | |
| 39 | + | t.Helper() | |
| 40 | + | dir := t.TempDir() | |
| 41 | + | listener, _, err := localadmin.Listen(dir) | |
| 36 | 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 | 65 | require.NoError(t, err) | |
| 40 | 66 | require.NotNil(t, handle) | |
| 41 | - | require.NotNil(t, handle.plane) | |
| 42 | - | defer handle.close() | |
| 43 | - | } | |
| 67 | + | assert.False(t, handle.isRemote) | |
| 68 | + | assert.IsType(t, &remote.Client{}, handle.plane) | |
| 69 | + | assert.Equal(t, 1, factoryCalls) | |
| 44 | 70 | ||
| 45 | - | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | |
| 46 | - | tmpDir := t.TempDir() | |
| 47 | - | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 48 | - | t.Setenv("GORDON_REMOTE", "") | |
| 49 | - | t.Setenv("GORDON_TOKEN", "") | |
| 50 | - | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |
| 51 | - | t.Cleanup(func() { | |
| 52 | - | remoteFlag = originalRemoteFlag | |
| 53 | - | tokenFlag = originalTokenFlag | |
| 54 | - | insecureTLSFlag = originalInsecureTLSFlag | |
| 55 | - | }) | |
| 71 | + | status, err := handle.plane.GetStatus(context.Background()) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | assert.Equal(t, "socket.local", status.RegistryDomain) | |
| 74 | + | select { | |
| 75 | + | case path := <-requests: | |
| 76 | + | assert.Equal(t, "/admin/status", path) | |
| 77 | + | case <-time.After(2 * time.Second): | |
| 78 | + | t.Fatal("socket server received no request") | |
| 79 | + | } | |
| 80 | + | } | |
| 56 | 81 | ||
| 57 | - | remoteFlag = "" | |
| 58 | - | tokenFlag = "" | |
| 59 | - | insecureTLSFlag = false | |
| 82 | + | func TestResolveControlPlane_LocalFailsClosedWhenDaemonUnavailable(t *testing.T) { | |
| 83 | + | isolateControlPlaneResolver(t) | |
| 84 | + | newLocalControlPlaneClient = func() (*remote.Client, error) { | |
| 85 | + | return nil, remote.ErrDaemonUnavailable | |
| 86 | + | } | |
| 60 | 87 | ||
| 61 | - | configPath := filepath.Join(tmpDir, "gordon.toml") | |
| 62 | - | err := os.WriteFile(configPath, []byte(`[server] | |
| 63 | - | gordon_domain = "gordon.local" | |
| 64 | - | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |
| 88 | + | handle, err := resolveControlPlane(filepath.Join(t.TempDir(), "would-create-store.toml")) | |
| 89 | + | require.Error(t, err) | |
| 90 | + | assert.ErrorIs(t, err, remote.ErrDaemonUnavailable) | |
| 91 | + | assert.Nil(t, handle) | |
| 92 | + | } | |
| 65 | 93 | ||
| 66 | - | [auth] | |
| 67 | - | enabled = false | |
| 68 | - | secrets_backend = "unsafe" | |
| 69 | - | `), 0o600) | |
| 70 | - | require.NoError(t, err) | |
| 94 | + | func TestResolveControlPlane_ExplicitRemoteDoesNotUseLocalFactory(t *testing.T) { | |
| 95 | + | isolateControlPlaneResolver(t) | |
| 96 | + | remoteFlag = "https://example.invalid" | |
| 97 | + | tokenFlag = "token" | |
| 98 | + | newLocalControlPlaneClient = func() (*remote.Client, error) { | |
| 99 | + | t.Fatal("local factory invoked for explicit remote") | |
| 100 | + | return nil, errors.New("unreachable") | |
| 101 | + | } | |
| 71 | 102 | ||
| 72 | - | handle, err := resolveControlPlane(configPath) | |
| 103 | + | handle, err := resolveControlPlane("") | |
| 73 | 104 | require.NoError(t, err) | |
| 74 | - | require.NotNil(t, handle) | |
| 75 | - | require.NotNil(t, handle.plane) | |
| 76 | - | defer handle.close() | |
| 105 | + | assert.True(t, handle.isRemote) | |
| 106 | + | assert.IsType(t, &remote.Client{}, handle.plane) | |
| 77 | 107 | } | |
| 78 | 108 | ||
| 79 | 109 | func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) { | |
| 80 | - | tmpDir := t.TempDir() | |
| 81 | - | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 82 | - | t.Setenv("GORDON_REMOTE", "") | |
| 83 | - | t.Setenv("GORDON_TOKEN", "") | |
| 84 | - | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |
| 85 | - | t.Cleanup(func() { | |
| 86 | - | remoteFlag = originalRemoteFlag | |
| 87 | - | tokenFlag = originalTokenFlag | |
| 88 | - | insecureTLSFlag = originalInsecureTLSFlag | |
| 89 | - | }) | |
| 90 | - | ||
| 110 | + | isolateControlPlaneResolver(t) | |
| 111 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg")) | |
| 91 | 112 | remoteFlag = "does-not-exist" | |
| 92 | - | tokenFlag = "" | |
| 93 | - | insecureTLSFlag = false | |
| 94 | 113 | ||
| 95 | - | configPath := filepath.Join(tmpDir, "gordon.toml") | |
| 96 | - | err := os.WriteFile(configPath, []byte(`[server] | |
| 97 | - | gordon_domain = "gordon.local" | |
| 98 | - | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |
| 114 | + | handle, err := resolveControlPlane("") | |
| 115 | + | require.Error(t, err) | |
| 116 | + | assert.Nil(t, handle) | |
| 117 | + | assert.Contains(t, err.Error(), "does-not-exist") | |
| 118 | + | } | |
| 99 | 119 | ||
| 100 | - | [auth] | |
| 101 | - | enabled = false | |
| 102 | - | secrets_backend = "unsafe" | |
| 103 | - | `), 0o600) | |
| 120 | + | func TestResolveControlPlane_LocalIgnoresConfigAndStoreState(t *testing.T) { | |
| 121 | + | isolateControlPlaneResolver(t) | |
| 122 | + | client := localControlPlaneTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 123 | + | w.Header().Set("Content-Type", "application/json") | |
| 124 | + | _ = json.NewEncoder(w).Encode(remote.Status{}) | |
| 125 | + | })) | |
| 126 | + | newLocalControlPlaneClient = func() (*remote.Client, error) { return client, nil } | |
| 127 | + | ||
| 128 | + | lockedStore := filepath.Join(t.TempDir(), "state.db") | |
| 129 | + | require.NoError(t, os.WriteFile(lockedStore, []byte("active daemon store"), 0o600)) | |
| 130 | + | handle, err := resolveControlPlane(lockedStore) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | _, err = handle.plane.GetStatus(context.Background()) | |
| 104 | 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 | 69 | func newImagesCmd() *cobra.Command { | |
| 70 | 70 | cmd := &cobra.Command{ | |
| 71 | 71 | Use: "images", | |
| 72 | - | Short: "List and prune images", | |
| 73 | - | Long: `Inspect and clean up runtime and registry images. | |
| 74 | - | ||
| 75 | - | These commands currently require remote mode with a configured target.`, | |
| 72 | + | Short: "Push, list, and prune images", | |
| 73 | + | Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`, | |
| 76 | 74 | } | |
| 77 | 75 | ||
| 76 | + | cmd.AddCommand(newPushCmd()) | |
| 78 | 77 | cmd.AddCommand(newImagesListCmd()) | |
| 79 | 78 | cmd.AddCommand(newImagesPruneCmd()) | |
| 80 | 79 | cmd.AddCommand(newImagesTagsCmd()) | |
| @@ -89,14 +88,10 @@ func newImagesListCmd() *cobra.Command { | |||
| 89 | 88 | Use: "list", | |
| 90 | 89 | Short: "List runtime images and registry tags", | |
| 91 | 90 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 92 | - | client, isRemote, err := GetRemoteClient() | |
| 91 | + | client, _, err := resolveDaemonClient() | |
| 93 | 92 | if err != nil { | |
| 94 | 93 | return err | |
| 95 | 94 | } | |
| 96 | - | if !isRemote { | |
| 97 | - | return fmt.Errorf("images commands require a configured remote target") | |
| 98 | - | } | |
| 99 | - | ||
| 100 | 95 | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) | |
| 101 | 96 | }, | |
| 102 | 97 | } | |
| @@ -115,21 +110,21 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 115 | 110 | Long: `Remove dangling runtime images and/or old registry tags. | |
| 116 | 111 | ||
| 117 | 112 | 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 | 118 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 120 | - | client, isRemote, err := GetRemoteClient() | |
| 119 | + | client, _, err := resolveDaemonClient() | |
| 121 | 120 | if err != nil { | |
| 122 | 121 | return err | |
| 123 | 122 | } | |
| 124 | - | if !isRemote { | |
| 125 | - | return fmt.Errorf("images commands require a configured remote target") | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 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 | 128 | cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast, | |
| 134 | 129 | "Number of previous non-latest tags to keep per repository (latest is always kept)") | |
| 135 | 130 | cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)") | |
| @@ -150,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command { | |||
| 150 | 145 | Examples: | |
| 151 | 146 | gordon images tags myapp | |
| 152 | 147 | 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 | 149 | Args: cobra.ExactArgs(1), | |
| 155 | 150 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 156 | 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 | 357 | resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry) | |
| 363 | 358 | if err != nil { | |
| @@ -378,12 +373,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 378 | 373 | } | |
| 379 | 374 | ||
| 380 | 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 | 380 | return err | |
| 383 | 381 | } | |
| 384 | 382 | ||
| 385 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 386 | - | return err | |
| 383 | + | return cliRenderPruneSummary(out, resp.Plan) | |
| 387 | 384 | } | |
| 388 | 385 | ||
| 389 | 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 | 388 | return err | |
| 392 | 389 | } | |
| 393 | 390 | ||
| 394 | - | if pruneDangling { | |
| 395 | - | images, err := client.ListImages(ctx) | |
| 396 | - | if err != nil { | |
| 397 | - | return fmt.Errorf("failed to list images: %w", err) | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | danglingCount := 0 | |
| 401 | - | for _, img := range images { | |
| 402 | - | if img.Dangling { | |
| 403 | - | danglingCount++ | |
| 404 | - | } | |
| 405 | - | } | |
| 391 | + | // A dry run executes the full inventory and planning path: only the | |
| 392 | + | // deletion step is skipped, so the report is the executed plan. | |
| 393 | + | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true) | |
| 394 | + | resp, err := client.PruneImages(ctx, req) | |
| 395 | + | if err != nil { | |
| 396 | + | return fmt.Errorf("failed to plan image prune: %w", err) | |
| 397 | + | } | |
| 398 | + | if resp == nil { | |
| 399 | + | return fmt.Errorf("failed to plan image prune: empty response") | |
| 400 | + | } | |
| 406 | 401 | ||
| 407 | - | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 408 | - | return err | |
| 409 | - | } | |
| 410 | - | } else { | |
| 411 | - | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 402 | + | if pruneDangling { | |
| 403 | + | if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil { | |
| 412 | 404 | 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 | 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 | 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 | 559 | 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 | 563 | return dto.ImagePruneRequest{ | |
| 566 | 564 | KeepLast: &keepReleases, | |
| 567 | 565 | PruneDangling: &pruneDangling, | |
| 568 | 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 | 298 | func TestRunImagesPrune_DryRunBothScopes(t *testing.T) { | |
| 299 | 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 | 314 | var out bytes.Buffer | |
| @@ -307,15 +318,22 @@ func TestRunImagesPrune_DryRunBothScopes(t *testing.T) { | |||
| 307 | 318 | ||
| 308 | 319 | text := out.String() | |
| 309 | 320 | assert.Contains(t, text, "Dry run") | |
| 310 | - | assert.Contains(t, text, "would prune 2 dangling runtime images") | |
| 311 | - | assert.Contains(t, text, "would keep latest + 3 previous tags") | |
| 312 | - | assert.Equal(t, 1, client.listImagesCalls) | |
| 313 | - | assert.Equal(t, 0, client.pruneCalls) | |
| 321 | + | assert.Contains(t, text, "would delete 2 images") | |
| 322 | + | assert.Contains(t, text, "latest + 3 previous tags") | |
| 323 | + | assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)") | |
| 324 | + | assert.Contains(t, text, "protected-active-service") | |
| 325 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 326 | + | assert.NotNil(t, client.lastPruneOpts.DryRun) | |
| 327 | + | assert.True(t, *client.lastPruneOpts.DryRun) | |
| 314 | 328 | } | |
| 315 | 329 | ||
| 316 | 330 | func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { | |
| 317 | 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 | 339 | var out bytes.Buffer | |
| @@ -324,14 +342,18 @@ func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { | |||
| 324 | 342 | require.NoError(t, err) | |
| 325 | 343 | ||
| 326 | 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 | 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 | 350 | func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) { | |
| 333 | 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 | 359 | var out bytes.Buffer | |
| @@ -341,8 +363,8 @@ func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) { | |||
| 341 | 363 | ||
| 342 | 364 | text := out.String() | |
| 343 | 365 | assert.Contains(t, text, "Runtime cleanup skipped") | |
| 344 | - | assert.Contains(t, text, "would keep latest + 5 previous tags") | |
| 345 | - | assert.Equal(t, 0, client.pruneCalls) | |
| 366 | + | assert.Contains(t, text, "latest + 5 previous tags") | |
| 367 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 346 | 368 | } | |
| 347 | 369 | ||
| 348 | 370 | // --------------------------------------------------------------------------- | |
| @@ -484,7 +506,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) { | |||
| 484 | 506 | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 485 | 507 | ||
| 486 | 508 | client := &imagesClientMock{ | |
| 487 | - | listImagesResp: []dto.Image{{Dangling: true}}, | |
| 509 | + | pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}}, | |
| 488 | 510 | } | |
| 489 | 511 | ||
| 490 | 512 | var out bytes.Buffer | |
| @@ -493,6 +515,7 @@ func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) { | |||
| 493 | 515 | require.NoError(t, err) | |
| 494 | 516 | ||
| 495 | 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 | 12 | name string | |
| 13 | 13 | builder func() *cobra.Command | |
| 14 | 14 | }{ | |
| 15 | - | {"routes list", newRoutesListCmd}, | |
| 16 | - | {"routes status", newRoutesStatusCmd}, | |
| 17 | - | {"attachments list", newAttachmentsListCmd}, | |
| 18 | - | {"secrets list", newSecretsListCmd}, | |
| 19 | 15 | {"images list", newImagesListCmd}, | |
| 20 | 16 | {"backup list", newBackupListCmd}, | |
| 21 | 17 | {"backup volumes list", newVolumeBackupListCmd}, | |
| @@ -23,12 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 23 | 19 | {"backup volumes status", newVolumeBackupStatusCmd}, | |
| 24 | 20 | {"auth token list", newTokenListCmd}, | |
| 25 | 21 | {"remotes list", newRemotesListCmd}, | |
| 26 | - | {"pin list", newPinListCmd}, | |
| 27 | - | {"config show", newConfigShowCmd}, | |
| 28 | - | {"routes show", newRoutesShowCmd}, | |
| 29 | - | {"networks list", newNetworksListCmd}, | |
| 22 | + | {"daemon config show", newConfigShowCmd}, | |
| 23 | + | {"daemon networks", newNetworksCmd}, | |
| 30 | 24 | {"images tags", newImagesTagsCmd}, | |
| 31 | - | {"tls status", newTLSStatusCmd}, | |
| 25 | + | {"daemon tls", newTLSCmd}, | |
| 32 | 26 | } | |
| 33 | 27 | ||
| 34 | 28 | for _, tc := range commands { | |
M internal/adapters/in/cli/json_test.go +4 -192
| @@ -10,37 +10,9 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | 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 | 16 | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |
| 45 | 17 | cmd := newImagesListCmd() | |
| 46 | 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 | 52 | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |
| 90 | 53 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 91 | 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 | 77 | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |
| 115 | 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 | 81 | payload, err := json.Marshal(jobs) | |
| 119 | 82 | require.NoError(t, err) | |
| @@ -122,7 +85,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |||
| 122 | 85 | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 123 | 86 | require.Len(t, got, 1) | |
| 124 | 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 | 93 | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |
| @@ -172,159 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||
| 172 | 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 | 140 | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |
| 329 | 141 | var out bytes.Buffer | |
| 330 | 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 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | - | "path/filepath" | |
| 8 | 7 | "strings" | |
| 9 | 8 | ||
| 10 | - | "github.com/bnema/zerowrap" | |
| 11 | 9 | "github.com/spf13/viper" | |
| 12 | 10 | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 14 | 11 | "github.com/bnema/gordon/internal/app" | |
| 15 | 12 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | - | "github.com/bnema/gordon/internal/domain" | |
| 18 | 13 | "github.com/bnema/gordon/internal/usecase/config" | |
| 19 | - | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 20 | 14 | ) | |
| 21 | 15 | ||
| 22 | 16 | // LocalServices provides direct access to local services for CLI operations. | |
| 23 | 17 | type LocalServices struct { | |
| 24 | 18 | configSvc in.ConfigService | |
| 25 | - | secretSvc in.SecretService | |
| 26 | 19 | dataDir string | |
| 27 | 20 | tlsEnabled bool | |
| 28 | 21 | } | |
| @@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService { | |||
| 32 | 25 | 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 | 28 | // GetDataDir returns the data directory. | |
| 41 | 29 | func (l *LocalServices) GetDataDir() string { | |
| 42 | 30 | return l.dataDir | |
| @@ -49,7 +37,7 @@ func (l *LocalServices) HasInternalTLS() bool { | |||
| 49 | 37 | ||
| 50 | 38 | // GetLocalServices creates local services for CLI operations. | |
| 51 | 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 | 41 | // Set up viper with defaults | |
| 54 | 42 | v := viper.New() | |
| 55 | 43 | v.SetDefault("server.port", 8088) | |
| @@ -57,7 +45,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 57 | 45 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | |
| 58 | 46 | ||
| 59 | 47 | // Configure viper with config file paths | |
| 60 | - | app.ConfigureViper(v, configPath) | |
| 48 | + | app.ConfigureViper(v, cliConfigPath) | |
| 61 | 49 | ||
| 62 | 50 | // Read config file | |
| 63 | 51 | if err := v.ReadInConfig(); err != nil { | |
| @@ -67,8 +55,6 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 67 | 55 | // Config file not found is OK for some operations | |
| 68 | 56 | } | |
| 69 | 57 | ||
| 70 | - | log := zerowrap.New(cliLogConfig) | |
| 71 | - | ||
| 72 | 58 | // Create config service (without event bus for CLI operations) | |
| 73 | 59 | configSvc := config.NewService(v, nil) | |
| 74 | 60 | ctx := context.Background() | |
| @@ -76,27 +62,13 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 76 | 62 | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 77 | 63 | } | |
| 78 | 64 | ||
| 79 | - | // Determine data directory and env directory | |
| 80 | 65 | dataDir := v.GetString("server.data_dir") | |
| 81 | 66 | if dataDir == "" { | |
| 82 | 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 | 70 | return &LocalServices{ | |
| 98 | 71 | configSvc: configSvc, | |
| 99 | - | secretSvc: secretSvc, | |
| 100 | 72 | dataDir: dataDir, | |
| 101 | 73 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | |
| 102 | 74 | }, nil | |
| @@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool { | |||
| 115 | 87 | } | |
| 116 | 88 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/zerowrap" | |
| 7 | 6 | "github.com/spf13/viper" | |
| 8 | 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 | 10 | func TestHasLocalTLSCapableEntrypoint(t *testing.T) { | |
| 42 | 11 | for _, tt := range []struct { | |
| 43 | 12 | 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 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockimagesClient { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockimagesClient{} | |
| 21 | 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 | 34 | 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 | 16 | mock.TestingT | |
| 17 | 17 | Cleanup(func()) | |
| 18 | 18 | }) *MockpushImageOps { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 19 | 23 | mock := &MockpushImageOps{} | |
| 20 | 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 | 33 | 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 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockvolumesClient { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockvolumesClient{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
M internal/adapters/in/cli/networks.go +6 -17
| @@ -12,32 +12,21 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 15 | var jsonOut bool | |
| 27 | 16 | ||
| 28 | 17 | cmd := &cobra.Command{ | |
| 29 | - | Use: "list", | |
| 30 | - | Short: "List Gordon-managed Docker networks", | |
| 18 | + | Use: "networks", | |
| 19 | + | Short: "List Gordon-managed networks", | |
| 31 | 20 | Long: `Display Docker networks managed by Gordon, including which containers | |
| 32 | 21 | are connected to each network. | |
| 33 | 22 | ||
| 34 | 23 | Examples: | |
| 35 | - | gordon networks list | |
| 36 | - | gordon networks list --json | |
| 37 | - | gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 24 | + | gordon daemon networks | |
| 25 | + | gordon daemon networks --json | |
| 26 | + | gordon daemon networks --remote prod`, | |
| 38 | 27 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 39 | 28 | ctx := cmd.Context() | |
| 40 | - | handle, err := resolveControlPlane(configPath) | |
| 29 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 41 | 30 | if err != nil { | |
| 42 | 31 | 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 | 63 | func cliRenderInfo(msg string) string { | |
| 64 | 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 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | - | "net/http" | |
| 10 | 9 | "os" | |
| 11 | 10 | "os/exec" | |
| 12 | 11 | "path/filepath" | |
| @@ -14,11 +13,9 @@ import ( | |||
| 14 | 13 | "strings" | |
| 15 | 14 | "time" | |
| 16 | 15 | ||
| 17 | - | tea "github.com/charmbracelet/bubbletea" | |
| 18 | 16 | "github.com/spf13/cobra" | |
| 19 | 17 | ||
| 20 | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 21 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 22 | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 23 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 24 | 21 | "github.com/bnema/gordon/pkg/validation" | |
| @@ -38,6 +35,7 @@ type buildConfig struct { | |||
| 38 | 35 | type imagePush struct { | |
| 39 | 36 | Registry string | |
| 40 | 37 | ImageName string | |
| 38 | + | SourceRef string | |
| 41 | 39 | Version string | |
| 42 | 40 | VersionRef string | |
| 43 | 41 | LatestRef string | |
| @@ -45,48 +43,33 @@ type imagePush struct { | |||
| 45 | 43 | ||
| 46 | 44 | // pushRequest holds all inputs for the push command. | |
| 47 | 45 | type pushRequest struct { | |
| 48 | - | ImageArg string | |
| 49 | - | Domain string | |
| 50 | - | Tag string | |
| 51 | - | Build buildConfig | |
| 52 | - | NoDeploy bool | |
| 53 | - | NoConfirm bool | |
| 46 | + | ImageArg string | |
| 47 | + | Tag string | |
| 48 | + | Build buildConfig | |
| 54 | 49 | } | |
| 55 | 50 | ||
| 56 | 51 | func newPushCmd() *cobra.Command { | |
| 57 | 52 | var ( | |
| 58 | - | noDeploy bool | |
| 59 | - | noConfirm bool | |
| 60 | 53 | build bool | |
| 61 | 54 | platform string | |
| 62 | 55 | tag string | |
| 63 | 56 | dockerfile string | |
| 64 | 57 | buildArgs []string | |
| 65 | - | domainFlag string | |
| 66 | 58 | ) | |
| 67 | 59 | ||
| 68 | 60 | cmd := &cobra.Command{ | |
| 69 | 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 | 63 | Long: `Tags a local image for the Gordon registry and pushes it. | |
| 72 | - | Uses git tags for versioning. Optionally triggers deployment after push. | |
| 73 | - | ||
| 74 | - | Image-first syntax is primary: | |
| 75 | - | - Positional args resolve routes by image name first | |
| 76 | - | - Tagged positional refs still resolve routes by image name | |
| 77 | - | - The pushed version comes from --tag, CI tag refs, or git describe | |
| 78 | - | - Dotted positional refs fall back to legacy domain lookup if no image route exists | |
| 79 | - | - --domain is a deploy target override for legacy workflows | |
| 80 | - | - No-arg mode still auto-detects from Dockerfile labels or current directory name | |
| 81 | - | ||
| 82 | - | With --build, builds the image first using docker buildx. | |
| 64 | + | Uses git tags for versioning. Push transfers OCI content only: it never | |
| 65 | + | deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying | |
| 66 | + | the manifest that references the pushed tag. | |
| 83 | 67 | ||
| 84 | 68 | Examples: | |
| 85 | - | gordon push myapp --build --remote ... | |
| 86 | - | gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ... | |
| 87 | - | gordon push registry.example.com/myapp:v1.2.3 --build --remote ... | |
| 88 | - | gordon push --domain myapp.example.com --build --remote ... | |
| 89 | - | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 69 | + | gordon images push myapp --build --remote ... | |
| 70 | + | gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ... | |
| 71 | + | gordon images push registry.example.com/myapp:v1.2.3 --build --remote ... | |
| 72 | + | gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 90 | 73 | Args: cobra.MaximumNArgs(1), | |
| 91 | 74 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 92 | 75 | var imageArg string | |
| @@ -94,24 +77,18 @@ Examples: | |||
| 94 | 77 | imageArg = args[0] | |
| 95 | 78 | } | |
| 96 | 79 | return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{ | |
| 97 | - | ImageArg: imageArg, | |
| 98 | - | Domain: domainFlag, | |
| 99 | - | Tag: tag, | |
| 100 | - | Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs}, | |
| 101 | - | NoDeploy: noDeploy, | |
| 102 | - | NoConfirm: noConfirm, | |
| 80 | + | ImageArg: imageArg, | |
| 81 | + | Tag: tag, | |
| 82 | + | Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs}, | |
| 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 | 87 | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |
| 110 | 88 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |
| 111 | 89 | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |
| 112 | 90 | cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)") | |
| 113 | 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 | 93 | return cmd | |
| 117 | 94 | } | |
| @@ -141,7 +118,8 @@ const ( | |||
| 141 | 118 | ||
| 142 | 119 | type classifiedPushArg struct { | |
| 143 | 120 | kind pushArgKind | |
| 144 | - | lookupImage string | |
| 121 | + | sourceRef string | |
| 122 | + | repository string | |
| 145 | 123 | legacyDomain string | |
| 146 | 124 | } | |
| 147 | 125 | ||
| @@ -150,11 +128,11 @@ func classifyPushArgument(arg string) classifiedPushArg { | |||
| 150 | 128 | return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg} | |
| 151 | 129 | } | |
| 152 | 130 | ||
| 153 | - | classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg} | |
| 154 | - | if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") { | |
| 155 | - | classified.lookupImage = parsedImage | |
| 131 | + | _, repository, _ := parseImageRef(arg) | |
| 132 | + | if repository == "" { | |
| 133 | + | repository, _ = validation.ParseImageReference(arg) | |
| 156 | 134 | } | |
| 157 | - | return classified | |
| 135 | + | return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository} | |
| 158 | 136 | } | |
| 159 | 137 | ||
| 160 | 138 | func looksLikeLegacyDomain(arg string) bool { | |
| @@ -168,51 +146,29 @@ func looksLikeLegacyDomain(arg string) bool { | |||
| 168 | 146 | return strings.Contains(host, ".") | |
| 169 | 147 | } | |
| 170 | 148 | ||
| 171 | - | // resolveRoute determines the registry, image name, and domain from the input mode. | |
| 172 | - | func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) { | |
| 173 | - | if domainFlag != "" { | |
| 174 | - | return resolveFromDomain(ctx, cp, domainFlag) | |
| 175 | - | } | |
| 176 | - | ||
| 149 | + | // resolveImageTarget determines the registry and image name from the input. | |
| 150 | + | // Push transfers OCI content only and never triggers deploys or route | |
| 151 | + | // lookups. The deploy target comes from the app manifest applied | |
| 152 | + | // separately via `gordon apps apply`. | |
| 153 | + | func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) { | |
| 177 | 154 | if imageArg == "" { | |
| 178 | 155 | imageArg, err = detectImageName(dockerfile) | |
| 179 | 156 | if err != nil { | |
| 180 | 157 | return "", "", "", err | |
| 181 | 158 | } | |
| 182 | - | fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg)) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | if looksLikeLegacyDomain(imageArg) { | |
| 186 | - | lookupImage := imageArg | |
| 187 | - | domainLookupArg := imageArg | |
| 188 | - | if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg { | |
| 189 | - | domainLookupArg = parsedImage | |
| 190 | - | if !strings.HasPrefix(ref, "sha256:") { | |
| 191 | - | lookupImage = parsedImage | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile) | |
| 196 | - | if err == nil { | |
| 197 | - | return registry, imageName, pushDomain, nil | |
| 198 | - | } | |
| 199 | - | if !isImageBootstrapError(err) { | |
| 159 | + | if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil { | |
| 200 | 160 | 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 | 166 | classified := classifyPushArgument(imageArg) | |
| 214 | - | ||
| 215 | - | return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile) | |
| 167 | + | registry, imageName, _ = parseImageRef(classified.sourceRef) | |
| 168 | + | if registry == "" || imageName == "" { | |
| 169 | + | return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg) | |
| 170 | + | } | |
| 171 | + | return registry, imageName, classified.sourceRef, nil | |
| 216 | 172 | } | |
| 217 | 173 | ||
| 218 | 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 | 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 | 193 | if err != nil { | |
| 238 | 194 | return err | |
| 239 | 195 | } | |
| @@ -246,7 +202,7 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||
| 246 | 202 | 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 | 206 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | |
| 251 | 207 | ||
| 252 | 208 | imageOps, err := newPushImageOps(inferredRemote) | |
| @@ -262,22 +218,14 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||
| 262 | 218 | 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 | 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 | 223 | return err | |
| 273 | 224 | } | |
| 274 | 225 | ||
| 275 | 226 | if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil { | |
| 276 | 227 | return err | |
| 277 | 228 | } | |
| 278 | - | if !skipExplicitDeploy { | |
| 279 | - | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | |
| 280 | - | } | |
| 281 | 229 | return nil | |
| 282 | 230 | } | |
| 283 | 231 | ||
| @@ -286,7 +234,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc | |||
| 286 | 234 | if err != nil { | |
| 287 | 235 | 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 | 238 | if err != nil { | |
| 291 | 239 | return "", nil, nil, err | |
| 292 | 240 | } | |
| @@ -297,7 +245,7 @@ func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (doc | |||
| 297 | 245 | } | |
| 298 | 246 | return dockerfile, inferredRemote, handle, nil | |
| 299 | 247 | } | |
| 300 | - | handle, err = resolveControlPlane(configPath) | |
| 248 | + | handle, err = resolveControlPlane(cliConfigPath) | |
| 301 | 249 | if err != nil { | |
| 302 | 250 | return "", nil, nil, err | |
| 303 | 251 | } | |
| @@ -320,179 +268,15 @@ func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error | |||
| 320 | 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 | 272 | if build.Enabled { | |
| 325 | - | return buildAndPush(ctx, imageOps, build, img) | |
| 326 | - | } | |
| 327 | - | return tagAndPush(ctx, imageOps, img) | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | |
| 331 | - | // should be skipped (either because --no-deploy was set, or the token lacks scope). | |
| 332 | - | func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool { | |
| 333 | - | // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy. | |
| 334 | - | // Without this, the server's event listener would still auto-deploy the pushed image. | |
| 335 | - | if err := plane.DeployIntent(ctx, imageName); err != nil { | |
| 336 | - | if isInsufficientScope(err) { | |
| 337 | - | fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive") | |
| 338 | - | return true | |
| 339 | - | } | |
| 340 | - | // Non-fatal: worst case we get a redundant deploy via event | |
| 341 | - | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |
| 342 | - | } | |
| 343 | - | return noDeploy | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response, | |
| 347 | - | // meaning the token lacks the required admin scope. | |
| 348 | - | func isInsufficientScope(err error) bool { | |
| 349 | - | var httpErr *remote.HTTPError | |
| 350 | - | return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // resolveFromDomain resolves image info from a domain name (legacy mode). | |
| 354 | - | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | |
| 355 | - | route, err := cp.GetRoute(ctx, pushDomain) | |
| 356 | - | if err != nil { | |
| 357 | - | return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err) | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | registry, imageName, _ = parseImageRef(route.Image) | |
| 361 | - | if registry == "" || imageName == "" { | |
| 362 | - | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image) | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | return registry, imageName, pushDomain, nil | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | // resolveFromImage resolves domain(s) from an image name using the backend. | |
| 369 | - | func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) { | |
| 370 | - | // First, query the backend to find routes for this image | |
| 371 | - | routes, err := cp.FindRoutesByImage(ctx, imageArg) | |
| 372 | - | if err != nil { | |
| 373 | - | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | routes = filterPreviewRoutes(routes) | |
| 377 | - | ||
| 378 | - | if len(routes) == 0 { | |
| 379 | - | return "", "", "", noRouteForImageError(imageArg) | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | // Pick the target domain | |
| 383 | - | var targetRoute domain.Route | |
| 384 | - | if len(routes) == 1 { | |
| 385 | - | targetRoute = routes[0] | |
| 386 | - | } else { | |
| 387 | - | // Multiple domains: check Dockerfile labels first, then prompt | |
| 388 | - | targetRoute, err = selectDomain(routes, dockerfile) | |
| 389 | - | if err != nil { | |
| 390 | - | return "", "", "", err | |
| 391 | - | } | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | registry, imageName, _ = parseImageRef(targetRoute.Image) | |
| 395 | - | if registry == "" || imageName == "" { | |
| 396 | - | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image) | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | return registry, imageName, targetRoute.Domain, nil | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | func isImageBootstrapError(err error) bool { | |
| 403 | - | return errors.Is(err, domain.ErrNoRouteForImage) | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | func noRouteForImageError(imageArg string) error { | |
| 407 | - | // bootstrap command is registered in bootstrap.go (see issue #98) | |
| 408 | - | return noRouteForImageBootstrapError{imageArg: imageArg} | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | type noRouteForImageBootstrapError struct { | |
| 412 | - | imageArg string | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | func (e noRouteForImageBootstrapError) Error() string { | |
| 416 | - | return fmt.Sprintf( | |
| 417 | - | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'", | |
| 418 | - | e.imageArg, | |
| 419 | - | e.imageArg, | |
| 420 | - | e.imageArg, | |
| 421 | - | e.imageArg, | |
| 422 | - | ) | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | func (e noRouteForImageBootstrapError) Unwrap() error { | |
| 426 | - | return domain.ErrNoRouteForImage | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | // selectDomain picks the target domain from multiple routes. | |
| 430 | - | // Checks Dockerfile labels first, then falls back to interactive selection. | |
| 431 | - | func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) { | |
| 432 | - | // Try to resolve from Dockerfile labels | |
| 433 | - | labels := parseDockerfileLabels(dockerfile) | |
| 434 | - | labelDomain := labels[domain.LabelDomain] | |
| 435 | - | labelDomains := labels[domain.LabelDomains] | |
| 436 | - | ||
| 437 | - | // Collect domains from labels | |
| 438 | - | var labelDomainList []string | |
| 439 | - | if labelDomain != "" { | |
| 440 | - | labelDomainList = append(labelDomainList, labelDomain) | |
| 441 | - | } | |
| 442 | - | if labelDomains != "" { | |
| 443 | - | for d := range strings.SplitSeq(labelDomains, ",") { | |
| 444 | - | d = strings.TrimSpace(d) | |
| 445 | - | if d != "" { | |
| 446 | - | labelDomainList = append(labelDomainList, d) | |
| 447 | - | } | |
| 448 | - | } | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | // Try to find a matching route from labels | |
| 452 | - | if len(labelDomainList) > 0 { | |
| 453 | - | routeMap := make(map[string]domain.Route, len(routes)) | |
| 454 | - | for _, r := range routes { | |
| 455 | - | routeMap[r.Domain] = r | |
| 456 | - | } | |
| 457 | - | for _, ld := range labelDomainList { | |
| 458 | - | if r, ok := routeMap[ld]; ok { | |
| 459 | - | return r, nil | |
| 460 | - | } | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | // Fall back to interactive selection | |
| 465 | - | items := make([]string, 0, len(routes)) | |
| 466 | - | for _, r := range routes { | |
| 467 | - | items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image))) | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | selected, err := components.RunSelector( | |
| 471 | - | "Multiple domains found for this image. Select target:", | |
| 472 | - | items, | |
| 473 | - | "", | |
| 474 | - | ) | |
| 475 | - | if err != nil { | |
| 476 | - | return domain.Route{}, fmt.Errorf("selection error: %w", err) | |
| 273 | + | return buildAndPush(ctx, out, imageOps, build, img) | |
| 477 | 274 | } | |
| 478 | - | if selected == "" { | |
| 479 | - | return domain.Route{}, fmt.Errorf("no domain selected") | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | // Extract domain from the selected display string | |
| 483 | - | for i, item := range items { | |
| 484 | - | if item == selected { | |
| 485 | - | return routes[i], nil | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | return domain.Route{}, fmt.Errorf("selected domain not found") | |
| 275 | + | return tagAndPush(ctx, out, imageOps, img) | |
| 490 | 276 | } | |
| 491 | 277 | ||
| 492 | - | // detectImageName auto-detects the image name from context. | |
| 493 | - | // Resolution order: | |
| 494 | - | // 1. Dockerfile label gordon.domain (if Dockerfile exists) | |
| 495 | - | // 2. Current directory name | |
| 278 | + | // detectImageName resolves the image name from Dockerfile labels or the | |
| 279 | + | // current directory name. | |
| 496 | 280 | func detectImageName(dockerfile string) (string, error) { | |
| 497 | 281 | // Try Dockerfile labels | |
| 498 | 282 | labels := parseDockerfileLabels(dockerfile) | |
| @@ -513,7 +297,7 @@ func detectImageName(dockerfile string) (string, error) { | |||
| 513 | 297 | ||
| 514 | 298 | dirName := filepath.Base(cwd) | |
| 515 | 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 | 303 | return dirName, nil | |
| @@ -655,7 +439,7 @@ func parseTagRef(ref string) string { | |||
| 655 | 439 | 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 | 443 | if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) { | |
| 660 | 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 | 447 | // Build and load into local daemon (NOT --push). | |
| 664 | 448 | // The native registry push client handles chunked uploads to stay | |
| 665 | 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 | 453 | if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil { | |
| 668 | 454 | return err | |
| 669 | 455 | } | |
| 670 | 456 | ||
| 671 | - | fmt.Println("Pushing...") | |
| 457 | + | if err := cliWriteLine(out, "Pushing..."); err != nil { | |
| 458 | + | return err | |
| 459 | + | } | |
| 672 | 460 | if err := ops.Push(ctx, img.LatestRef); err != nil { | |
| 673 | 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 | 519 | return args | |
| 732 | 520 | } | |
| 733 | 521 | ||
| 734 | - | func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error { | |
| 735 | - | localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName) | |
| 522 | + | func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error { | |
| 523 | + | localImage := img.SourceRef | |
| 524 | + | if localImage == "" { | |
| 525 | + | return errors.New("push source image reference cannot be empty") | |
| 526 | + | } | |
| 736 | 527 | ||
| 737 | - | fmt.Println("\nChecking local image...") | |
| 528 | + | if err := cliWriteLine(out, "\nChecking local image..."); err != nil { | |
| 529 | + | return err | |
| 530 | + | } | |
| 738 | 531 | exists, err := ops.Exists(ctx, localImage) | |
| 739 | 532 | if err != nil { | |
| 740 | 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 | 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 | 542 | if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil { | |
| 748 | 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 | 554 | if err := ops.Push(ctx, img.VersionRef); err != nil { | |
| 758 | 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 | 562 | 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 | 565 | func isInteractiveTerminal() bool { | |
| 881 | 566 | term := os.Getenv("TERM") | |
| 882 | 567 | if term == "" || term == "dumb" { | |
| @@ -898,6 +583,9 @@ func parseImageRef(image string) (registry, name, tag string) { | |||
| 898 | 583 | } | |
| 899 | 584 | registry = parts[0] | |
| 900 | 585 | nameTag := parts[1] | |
| 586 | + | if idx := strings.LastIndex(nameTag, "@"); idx != -1 { | |
| 587 | + | return registry, nameTag[:idx], nameTag[idx+1:] | |
| 588 | + | } | |
| 901 | 589 | if idx := strings.LastIndex(nameTag, ":"); idx != -1 { | |
| 902 | 590 | name = nameTag[:idx] | |
| 903 | 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 | 35 | type dockerImageOps struct { | |
| 36 | 36 | cli client.APIClient | |
| 37 | 37 | insecureTLS bool | |
| 38 | + | plainHTTP bool | |
| 38 | 39 | progress io.Writer | |
| 39 | 40 | remoteClient *remote.Client // nil when local | |
| 40 | 41 | } | |
| @@ -70,8 +71,11 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps | |||
| 70 | 71 | if err != nil { | |
| 71 | 72 | return nil, err | |
| 72 | 73 | } | |
| 73 | - | if resolved != nil && resolved.Token != "" { | |
| 74 | - | ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| 74 | + | if resolved != nil { | |
| 75 | + | ops.plainHTTP = strings.HasPrefix(strings.ToLower(resolved.URL), "http://") | |
| 76 | + | if resolved.Token != "" { | |
| 77 | + | ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| 78 | + | } | |
| 75 | 79 | } | |
| 76 | 80 | return ops, nil | |
| 77 | 81 | } | |
| @@ -87,6 +91,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |||
| 87 | 91 | opts := []registrypush.Option{ | |
| 88 | 92 | registrypush.WithProgress(d.progress), | |
| 89 | 93 | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 94 | + | registrypush.WithPlainHTTP(d.plainHTTP), | |
| 90 | 95 | } | |
| 91 | 96 | ||
| 92 | 97 | if d.remoteClient != nil { | |
| @@ -107,6 +112,7 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |||
| 107 | 112 | retryOpts := []registrypush.Option{ | |
| 108 | 113 | registrypush.WithProgress(d.progress), | |
| 109 | 114 | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 115 | + | registrypush.WithPlainHTTP(d.plainHTTP), | |
| 110 | 116 | registrypush.WithAuth(authHeader), | |
| 111 | 117 | } | |
| 112 | 118 | pusher = registrypush.New(retryOpts...) | |
| @@ -175,7 +181,7 @@ func (d *dockerImageOps) Build(ctx context.Context, args []string) error { | |||
| 175 | 181 | "Podman and other runtimes are not supported for --build.\n" + | |
| 176 | 182 | "Build the image manually and push with:\n" + | |
| 177 | 183 | " podman build -t <image> .\n" + | |
| 178 | - | " gordon push <image>", | |
| 184 | + | " gordon images push <image>", | |
| 179 | 185 | ) | |
| 180 | 186 | } | |
| 181 | 187 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "net/http/httptest" | |
| 8 | 9 | "strings" | |
| @@ -33,9 +34,13 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) { | |||
| 33 | 34 | img, err := random.Image(512, 1) | |
| 34 | 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 | 41 | ops := climocks.NewMockpushImageOps(t) | |
| 37 | 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 | 44 | ops.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ref string) error { | |
| 40 | 45 | pusher := registrypush.New( | |
| 41 | 46 | registrypush.WithChunkSize(256), | |
| @@ -47,13 +52,10 @@ func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) { | |||
| 47 | 52 | return pusher.Push(ctx, ref) | |
| 48 | 53 | }) | |
| 49 | 54 | ||
| 50 | - | registry := strings.TrimPrefix(harness.server.URL, "http://") | |
| 51 | - | versionRef := registry + "/testapp:v1.0.0" | |
| 52 | - | latestRef := registry + "/testapp:latest" | |
| 53 | - | ||
| 54 | - | err = tagAndPush(ctx, ops, imagePush{ | |
| 55 | + | err = tagAndPush(ctx, io.Discard, ops, imagePush{ | |
| 55 | 56 | Registry: registry, | |
| 56 | 57 | ImageName: "testapp", | |
| 58 | + | SourceRef: versionRef, | |
| 57 | 59 | Version: "v1.0.0", | |
| 58 | 60 | VersionRef: versionRef, | |
| 59 | 61 | LatestRef: latestRef, | |
M internal/adapters/in/cli/push_remote_inference_test.go +19 -414
| @@ -13,16 +13,14 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | - | ||
| 17 | - | "github.com/bnema/gordon/internal/domain" | |
| 18 | 16 | ) | |
| 19 | 17 | ||
| 20 | 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 | 20 | assert.Equal(t, "myapp", image) | |
| 23 | - | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 21 | + | return []string{"latest"}, http.StatusOK | |
| 24 | 22 | }) | |
| 25 | - | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 23 | + | staging := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 26 | 24 | assert.Equal(t, "myapp", image) | |
| 27 | 25 | return nil, http.StatusOK | |
| 28 | 26 | }) | |
| @@ -43,11 +41,11 @@ url = "`+staging.URL+`" | |||
| 43 | 41 | } | |
| 44 | 42 | ||
| 45 | 43 | func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |
| 46 | - | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 47 | - | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 44 | + | prod := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 45 | + | return []string{"latest"}, http.StatusOK | |
| 48 | 46 | }) | |
| 49 | - | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 50 | - | return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 47 | + | staging := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 48 | + | return []string{"latest"}, http.StatusOK | |
| 51 | 49 | }) | |
| 52 | 50 | ||
| 53 | 51 | configurePushRemoteInferenceTestEnv(t, ` | |
| @@ -73,9 +71,9 @@ func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) { | |||
| 73 | 71 | }) | |
| 74 | 72 | ||
| 75 | 73 | called := false | |
| 76 | - | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 74 | + | prod := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 77 | 75 | called = true | |
| 78 | - | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 76 | + | return []string{"latest"}, http.StatusOK | |
| 79 | 77 | }) | |
| 80 | 78 | ||
| 81 | 79 | configurePushRemoteInferenceTestEnv(t, ` | |
| @@ -93,9 +91,9 @@ url = "`+prod.URL+`" | |||
| 93 | 91 | ||
| 94 | 92 | func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) { | |
| 95 | 93 | called := false | |
| 96 | - | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 94 | + | prod := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 97 | 95 | called = true | |
| 98 | - | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 96 | + | return []string{"latest"}, http.StatusOK | |
| 99 | 97 | }) | |
| 100 | 98 | ||
| 101 | 99 | configurePushRemoteInferenceTestEnv(t, ` | |
| @@ -111,26 +109,11 @@ url = "`+prod.URL+`" | |||
| 111 | 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 | 112 | func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) { | |
| 130 | - | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 131 | - | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 113 | + | prod := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 114 | + | return []string{"latest"}, http.StatusOK | |
| 132 | 115 | }) | |
| 133 | - | broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 116 | + | broken := newTagsTestServer(t, func(image string) ([]string, int) { | |
| 134 | 117 | return nil, http.StatusInternalServerError | |
| 135 | 118 | }) | |
| 136 | 119 | ||
| @@ -149,199 +132,6 @@ url = "`+broken.URL+`" | |||
| 149 | 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 | 135 | func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |
| 346 | 136 | prod := newTagsTestServer(t, func(repository string) ([]string, int) { | |
| 347 | 137 | return []string{"latest", "v1.0.0"}, http.StatusOK | |
| @@ -386,205 +176,20 @@ func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) { | |||
| 386 | 176 | ||
| 387 | 177 | configHome := t.TempDir() | |
| 388 | 178 | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 389 | - | configPath := filepath.Join(configHome, "gordon", "remotes.toml") | |
| 390 | - | require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) | |
| 391 | - | require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600)) | |
| 179 | + | cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml") | |
| 180 | + | require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755)) | |
| 181 | + | require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600)) | |
| 392 | 182 | } | |
| 393 | 183 | ||
| 394 | - | func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server { | |
| 395 | - | t.Helper() | |
| 396 | - | ||
| 397 | - | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 398 | - | if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") { | |
| 399 | - | http.NotFound(w, r) | |
| 400 | - | return | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/")) | |
| 404 | - | if !assert.NoError(t, err) { | |
| 405 | - | return | |
| 406 | - | } | |
| 407 | - | ||
| 408 | - | routes, status := handler(image) | |
| 409 | - | if status >= http.StatusBadRequest { | |
| 410 | - | w.Header().Set("Content-Type", "application/json") | |
| 411 | - | w.WriteHeader(status) | |
| 412 | - | if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) { | |
| 413 | - | return | |
| 414 | - | } | |
| 415 | - | return | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | w.Header().Set("Content-Type", "application/json") | |
| 419 | - | if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ | |
| 420 | - | "image": image, | |
| 421 | - | "routes": routes, | |
| 422 | - | })) { | |
| 423 | - | return | |
| 424 | - | } | |
| 425 | - | })) | |
| 426 | - | t.Cleanup(server.Close) | |
| 427 | - | return server | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server { | |
| 431 | - | t.Helper() | |
| 432 | - | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler}) | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | type multiAdminProbeHandlers struct { | |
| 436 | - | getRoute func(domainName string) (*domain.Route, int) | |
| 437 | - | getRouteCleanup func(domainName string) (*domain.CleanupReport, int) | |
| 438 | - | getAttachmentsConfig func(target string) ([]string, int) | |
| 439 | - | listTags func(repository string) ([]string, int) | |
| 440 | - | findAttachmentTarget func(image string) ([]string, int) | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server { | |
| 184 | + | func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server { | |
| 444 | 185 | t.Helper() | |
| 445 | - | ||
| 446 | 186 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 447 | - | switch { | |
| 448 | - | case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"): | |
| 449 | - | handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup) | |
| 450 | - | case strings.HasPrefix(r.URL.Path, "/admin/routes/"): | |
| 451 | - | handleGetRouteProbe(t, w, r, handlers.getRoute) | |
| 452 | - | case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"): | |
| 453 | - | handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget) | |
| 454 | - | case strings.HasPrefix(r.URL.Path, "/admin/attachments/"): | |
| 455 | - | handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig) | |
| 456 | - | case strings.HasPrefix(r.URL.Path, "/admin/tags/"): | |
| 457 | - | handleListTagsProbe(t, w, r, handlers.listTags) | |
| 458 | - | default: | |
| 459 | - | http.NotFound(w, r) | |
| 460 | - | } | |
| 187 | + | handleListTagsProbe(t, w, r, handler) | |
| 461 | 188 | })) | |
| 462 | 189 | t.Cleanup(server.Close) | |
| 463 | 190 | 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 | 193 | func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) { | |
| 589 | 194 | t.Helper() | |
| 590 | 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 | 297 | Examples: | |
| 298 | 298 | gordon remotes use prod | |
| 299 | - | gordon routes list # Uses prod remote automatically`, | |
| 299 | + | gordon daemon status # Uses prod remote automatically`, | |
| 300 | 300 | Args: cobra.ExactArgs(1), | |
| 301 | 301 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 302 | 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 | 11 | // newServeCmd creates the serve command. | |
| 12 | 12 | func newServeCmd() *cobra.Command { | |
| 13 | - | var configPath string | |
| 13 | + | var cliConfigPath string | |
| 14 | 14 | ||
| 15 | 15 | cmd := &cobra.Command{ | |
| 16 | 16 | Use: "serve", | |
| 17 | 17 | Short: "Start the Gordon server", | |
| 18 | 18 | Long: `Start the Gordon server, including the registry and proxy components.`, | |
| 19 | 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 | 24 | // 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 | 27 | 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 | 16 | var tlsResolveControlPlane = resolveControlPlane | |
| 17 | 17 | ||
| 18 | 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 | 19 | var jsonOut bool | |
| 31 | 20 | ||
| 32 | 21 | cmd := &cobra.Command{ | |
| 33 | - | Use: "status", | |
| 22 | + | Use: "tls", | |
| 34 | 23 | Short: "Show public TLS certificate status", | |
| 35 | 24 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 36 | - | handle, err := tlsResolveControlPlane(configPath) | |
| 25 | + | handle, err := tlsResolveControlPlane(cliConfigPath) | |
| 37 | 26 | if err != nil { | |
| 38 | 27 | 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
| @@ -13,7 +13,6 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 16 | - | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 17 | 16 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | |
| 18 | 17 | ) | |
| 19 | 18 | ||
| @@ -179,485 +178,8 @@ func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T | |||
| 179 | 178 | }, images[2]) | |
| 180 | 179 | } | |
| 181 | 180 | ||
| 182 | - | func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) { | |
| 183 | - | manifestStorage := noopManifestStorage{} | |
| 184 | - | blobStorage := noopBlobStorage{} | |
| 185 | - | rt := &fakeRuntime{ | |
| 186 | - | pruneReport: pkgruntime.PruneReport{ | |
| 187 | - | DeletedIDs: []string{"sha256:a", "sha256:b"}, | |
| 188 | - | SpaceReclaimed: 2048, | |
| 189 | - | }, | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 193 | - | ||
| 194 | - | report, err := svc.PruneRuntime(context.Background()) | |
| 195 | - | ||
| 196 | - | require.NoError(t, err) | |
| 197 | - | assert.Equal(t, 2, report.Runtime.DeletedCount) | |
| 198 | - | assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed) | |
| 199 | - | assert.True(t, rt.pruneCalled) | |
| 200 | - | assert.True(t, rt.pruneDanglingOnly) | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | func TestService_PruneRuntime_NoDanglingImages(t *testing.T) { | |
| 204 | - | manifestStorage := noopManifestStorage{} | |
| 205 | - | blobStorage := noopBlobStorage{} | |
| 206 | - | rt := &fakeRuntime{ | |
| 207 | - | pruneReport: pkgruntime.PruneReport{ | |
| 208 | - | DeletedIDs: nil, | |
| 209 | - | SpaceReclaimed: 0, | |
| 210 | - | }, | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 214 | - | ||
| 215 | - | report, err := svc.PruneRuntime(context.Background()) | |
| 216 | - | ||
| 217 | - | require.NoError(t, err) | |
| 218 | - | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| 219 | - | assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed) | |
| 220 | - | assert.True(t, rt.pruneCalled) | |
| 221 | - | assert.True(t, rt.pruneDanglingOnly) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |
| 225 | - | manifestStorage := noopManifestStorage{} | |
| 226 | - | blobStorage := noopBlobStorage{} | |
| 227 | - | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | |
| 228 | - | ||
| 229 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 230 | - | ||
| 231 | - | _, err := svc.PruneRuntime(context.Background()) | |
| 232 | - | ||
| 233 | - | require.Error(t, err) | |
| 234 | - | assert.Contains(t, err.Error(), "failed to prune runtime images") | |
| 235 | - | assert.True(t, rt.pruneCalled) | |
| 236 | - | assert.True(t, rt.pruneDanglingOnly) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |
| 240 | - | manifestStorage := newFakeManifestStorage() | |
| 241 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 242 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"} | |
| 243 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 244 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 245 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 246 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | |
| 247 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest") | |
| 248 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3") | |
| 249 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 250 | - | ||
| 251 | - | blobStorage := &fakeBlobStorage{} | |
| 252 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 253 | - | ||
| 254 | - | report, err := svc.PruneRegistry(context.Background(), 2) | |
| 255 | - | ||
| 256 | - | require.NoError(t, err) | |
| 257 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 258 | - | assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) { | |
| 262 | - | manifestStorage := newFakeManifestStorage() | |
| 263 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 264 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"} | |
| 265 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC) | |
| 266 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 267 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 268 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | |
| 269 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared") | |
| 270 | - | ||
| 271 | - | blobStorage := &fakeBlobStorage{} | |
| 272 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 273 | - | ||
| 274 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 275 | - | ||
| 276 | - | require.NoError(t, err) | |
| 277 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 278 | - | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests) | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) { | |
| 282 | - | manifestStorage := newFakeManifestStorage() | |
| 283 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 284 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 285 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 286 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 287 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest") | |
| 288 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1") | |
| 289 | - | ||
| 290 | - | blobStorage := &fakeBlobStorage{} | |
| 291 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 292 | - | ||
| 293 | - | report, err := svc.PruneRegistry(context.Background(), 5) | |
| 294 | - | ||
| 295 | - | require.NoError(t, err) | |
| 296 | - | assert.Equal(t, 0, report.Registry.TagsRemoved) | |
| 297 | - | assert.Empty(t, manifestStorage.deletedManifests) | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) { | |
| 301 | - | manifestStorage := newFakeManifestStorage() | |
| 302 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 303 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}} | |
| 304 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 305 | - | ||
| 306 | - | report, err := svc.PruneRegistry(context.Background(), 0) | |
| 307 | - | ||
| 308 | - | require.NoError(t, err) | |
| 309 | - | assert.Equal(t, domain.RegistryPruneResult{}, report.Registry) | |
| 310 | - | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls) | |
| 311 | - | assert.Empty(t, manifestStorage.deletedManifests) | |
| 312 | - | assert.Empty(t, blobStorage.deletedBlobs) | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |
| 316 | - | manifestStorage := newFakeManifestStorage() | |
| 317 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 318 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 319 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 320 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 321 | - | ||
| 322 | - | blobStorage := &fakeBlobStorage{ | |
| 323 | - | blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}, | |
| 324 | - | blobSizes: map[string]int64{"sha256:orphan": 4096}, | |
| 325 | - | } | |
| 326 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 327 | - | ||
| 328 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 329 | - | ||
| 330 | - | require.NoError(t, err) | |
| 331 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 332 | - | assert.Equal(t, int64(4096), report.Registry.SpaceReclaimed) | |
| 333 | - | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | func TestService_PruneRegistry_PreservesRecentlyFinalizedBlobAfterRestart(t *testing.T) { | |
| 337 | - | manifestStorage := newFakeManifestStorage() | |
| 338 | - | blobStorage := &fakeBlobStorage{ | |
| 339 | - | blobs: []string{"sha256:recent"}, | |
| 340 | - | blobModTimes: map[string]time.Time{"sha256:recent": time.Now().UTC()}, | |
| 341 | - | } | |
| 342 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), registrystate.New()) | |
| 343 | - | ||
| 344 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 345 | - | ||
| 346 | - | require.NoError(t, err) | |
| 347 | - | assert.Zero(t, report.Registry.BlobsRemoved) | |
| 348 | - | assert.Empty(t, blobStorage.deletedBlobs) | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) { | |
| 352 | - | manifestStorage := newFakeManifestStorage() | |
| 353 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 354 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 355 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC() | |
| 356 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 357 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}} | |
| 358 | - | state := registrystate.New() | |
| 359 | - | state.AddPending("sha256:pending", time.Now().UTC()) | |
| 360 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state) | |
| 361 | - | ||
| 362 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 363 | - | ||
| 364 | - | require.NoError(t, err) | |
| 365 | - | assert.Zero(t, report.Registry.BlobsRemoved) | |
| 366 | - | assert.Empty(t, blobStorage.deletedBlobs) | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | |
| 370 | - | manifestStorage := newFakeManifestStorage() | |
| 371 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 372 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 373 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 374 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 375 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 376 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | |
| 377 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared") | |
| 378 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared") | |
| 379 | - | ||
| 380 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}} | |
| 381 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 382 | - | ||
| 383 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 384 | - | ||
| 385 | - | require.NoError(t, err) | |
| 386 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 387 | - | assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs) | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) { | |
| 391 | - | manifestStorage := newFakeManifestStorage() | |
| 392 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 393 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 394 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 395 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64") | |
| 396 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64") | |
| 397 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64") | |
| 398 | - | ||
| 399 | - | blobStorage := &fakeBlobStorage{blobs: []string{ | |
| 400 | - | "sha256:child-amd64", | |
| 401 | - | "sha256:child-arm64", | |
| 402 | - | "sha256:cfg-amd64", | |
| 403 | - | "sha256:layer-amd64", | |
| 404 | - | "sha256:cfg-arm64", | |
| 405 | - | "sha256:layer-arm64", | |
| 406 | - | "sha256:orphan", | |
| 407 | - | }} | |
| 408 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 409 | - | ||
| 410 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 411 | - | ||
| 412 | - | require.NoError(t, err) | |
| 413 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 414 | - | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) { | |
| 418 | - | manifestStorage := newFakeManifestStorage() | |
| 419 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 420 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"} | |
| 421 | - | tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 422 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie | |
| 423 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie | |
| 424 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 425 | - | ||
| 426 | - | blobStorage := &fakeBlobStorage{} | |
| 427 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 428 | - | ||
| 429 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 430 | - | ||
| 431 | - | require.NoError(t, err) | |
| 432 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 433 | - | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func TestService_PruneRegistry_LogsWarningAndContinuesWhenChildManifestMissing(t *testing.T) { | |
| 437 | - | manifestStorage := newFakeManifestStorage() | |
| 438 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 439 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 440 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 441 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing") | |
| 442 | - | ||
| 443 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}} | |
| 444 | - | logger := zerowrap.Default() | |
| 445 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, logger) | |
| 446 | - | ||
| 447 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 448 | - | ||
| 449 | - | // Should not error - missing child manifests are logged as warnings and skipped | |
| 450 | - | require.NoError(t, err) | |
| 451 | - | assert.Equal(t, 0, report.Registry.TagsRemoved, "no tags should be removed (latest is kept)") | |
| 452 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved, "orphan blob should be garbage collected") | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | func TestService_PruneRegistry_MultipleRepositories(t *testing.T) { | |
| 456 | - | manifestStorage := newFakeManifestStorage() | |
| 457 | - | manifestStorage.repositories = []string{"gordon/api", "gordon/web"} | |
| 458 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 459 | - | manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"} | |
| 460 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 461 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 462 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 463 | - | manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 464 | - | manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 465 | - | manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 466 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest") | |
| 467 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2") | |
| 468 | - | manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest") | |
| 469 | - | manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3") | |
| 470 | - | ||
| 471 | - | blobStorage := &fakeBlobStorage{} | |
| 472 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 473 | - | ||
| 474 | - | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 475 | - | ||
| 476 | - | require.NoError(t, err) | |
| 477 | - | assert.Equal(t, 2, report.Registry.TagsRemoved) | |
| 478 | - | assert.ElementsMatch(t, []manifestRef{ | |
| 479 | - | {name: "gordon/api", reference: "v1"}, | |
| 480 | - | {name: "gordon/web", reference: "v2"}, | |
| 481 | - | }, manifestStorage.deletedManifests) | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |
| 485 | - | manifestStorage := newFakeManifestStorage() | |
| 486 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 487 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{} | |
| 488 | - | blobStorage := &fakeBlobStorage{} | |
| 489 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 490 | - | ||
| 491 | - | report, err := svc.PruneRegistry(context.Background(), 2) | |
| 492 | - | ||
| 493 | - | require.NoError(t, err) | |
| 494 | - | assert.Equal(t, domain.RegistryPruneResult{}, report.Registry) | |
| 495 | - | assert.Empty(t, manifestStorage.deletedManifests) | |
| 496 | - | assert.Empty(t, blobStorage.deletedBlobs) | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) { | |
| 500 | - | manifestStorage := newFakeManifestStorage() | |
| 501 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 502 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 503 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 504 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 505 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 506 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 507 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 508 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 509 | - | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |
| 510 | - | ||
| 511 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 512 | - | ||
| 513 | - | // First prune - should remove v1 and orphan | |
| 514 | - | report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 515 | - | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 516 | - | }) | |
| 517 | - | require.NoError(t, err) | |
| 518 | - | assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1") | |
| 519 | - | assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan") | |
| 520 | - | ||
| 521 | - | // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time) | |
| 522 | - | rt.pruneReport = pkgruntime.PruneReport{} | |
| 523 | - | ||
| 524 | - | // Second prune - should be idempotent (nothing else to remove) | |
| 525 | - | report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 526 | - | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 527 | - | }) | |
| 528 | - | require.NoError(t, err) | |
| 529 | - | assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)") | |
| 530 | - | assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)") | |
| 531 | - | assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images") | |
| 532 | - | assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space") | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |
| 536 | - | manifestStorage := newFakeManifestStorage() | |
| 537 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 538 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 539 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 540 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 541 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 542 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 543 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 544 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 545 | - | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |
| 546 | - | ||
| 547 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 548 | - | ||
| 549 | - | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 550 | - | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 551 | - | }) | |
| 552 | - | ||
| 553 | - | require.NoError(t, err) | |
| 554 | - | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| 555 | - | assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed) | |
| 556 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 557 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 558 | - | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |
| 559 | - | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |
| 563 | - | manifestStorage := newFakeManifestStorage() | |
| 564 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 565 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 566 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 567 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 568 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 569 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 570 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 571 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 572 | - | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | |
| 573 | - | ||
| 574 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 575 | - | ||
| 576 | - | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 577 | - | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 578 | - | }) | |
| 579 | - | ||
| 580 | - | require.NoError(t, err) | |
| 581 | - | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| 582 | - | assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed) | |
| 583 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 584 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 585 | - | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |
| 586 | - | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 587 | - | } | |
| 588 | - | ||
| 589 | - | func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) { | |
| 590 | - | manifestStorage := newFakeManifestStorage() | |
| 591 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 592 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 593 | - | blobStorage := &fakeBlobStorage{} | |
| 594 | - | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}} | |
| 595 | - | ||
| 596 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 597 | - | ||
| 598 | - | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 599 | - | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |
| 600 | - | }) | |
| 601 | - | ||
| 602 | - | require.NoError(t, err) | |
| 603 | - | assert.True(t, rt.pruneCalled, "runtime prune should have been called") | |
| 604 | - | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| 605 | - | assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned") | |
| 606 | - | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled") | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) { | |
| 610 | - | rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")} | |
| 611 | - | svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default()) | |
| 612 | - | ||
| 613 | - | _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 614 | - | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |
| 615 | - | }) | |
| 616 | - | ||
| 617 | - | require.Error(t, err) | |
| 618 | - | assert.Contains(t, err.Error(), "runtime prune failed") | |
| 619 | - | assert.Contains(t, err.Error(), "docker daemon unavailable") | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | func TestService_PruneRegistry_CleansUpStaleUploads(t *testing.T) { | |
| 623 | - | manifestStorage := newFakeManifestStorage() | |
| 624 | - | blobStorage := &fakeCleanupBlobStorage{ | |
| 625 | - | fakeBlobStorage: &fakeBlobStorage{}, | |
| 626 | - | uploadsRemoved: 3, | |
| 627 | - | uploadBytes: 1024 * 1024, | |
| 628 | - | } | |
| 629 | - | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 630 | - | ||
| 631 | - | report, err := svc.PruneRegistry(context.Background(), 5) | |
| 632 | - | require.NoError(t, err) | |
| 633 | - | assert.Equal(t, 3, report.Registry.UploadsRemoved) | |
| 634 | - | assert.Equal(t, int64(1024*1024), report.Registry.UploadSpaceReclaimed) | |
| 635 | - | assert.Equal(t, 24*time.Hour, blobStorage.capturedMaxAge) | |
| 636 | - | } | |
| 637 | - | ||
| 638 | - | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |
| 639 | - | manifestStorage := newFakeManifestStorage() | |
| 640 | - | manifestStorage.repositories = []string{"gordon/api"} | |
| 641 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 642 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 643 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 644 | - | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 645 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 646 | - | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 647 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 648 | - | rt := &fakeRuntime{} | |
| 649 | - | ||
| 650 | - | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 651 | - | ||
| 652 | - | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 653 | - | KeepLast: 1, PruneDangling: false, PruneRegistry: true, | |
| 654 | - | }) | |
| 655 | - | ||
| 656 | - | require.NoError(t, err) | |
| 657 | - | assert.False(t, rt.pruneCalled, "runtime prune should not have been called") | |
| 658 | - | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| 659 | - | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 660 | - | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 181 | + | type noopBlobStorage struct { | |
| 182 | + | out.BlobStorage | |
| 661 | 183 | } | |
| 662 | 184 | ||
| 663 | 185 | type fakeRuntime struct { | |
| @@ -665,19 +187,6 @@ type fakeRuntime struct { | |||
| 665 | 187 | ||
| 666 | 188 | listDetails []pkgruntime.ImageDetail | |
| 667 | 189 | listErr error | |
| 668 | - | ||
| 669 | - | pruneReport pkgruntime.PruneReport | |
| 670 | - | pruneErr error | |
| 671 | - | pruneCalled bool | |
| 672 | - | pruneDanglingOnly bool | |
| 673 | - | } | |
| 674 | - | ||
| 675 | - | type noopManifestStorage struct { | |
| 676 | - | out.ManifestStorage | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | type noopBlobStorage struct { | |
| 680 | - | out.BlobStorage | |
| 681 | 190 | } | |
| 682 | 191 | ||
| 683 | 192 | type manifestRef struct { | |
| @@ -695,39 +204,23 @@ type fakeManifestStorage struct { | |||
| 695 | 204 | ||
| 696 | 205 | listRepositoriesCalls int | |
| 697 | 206 | deletedManifests []manifestRef | |
| 207 | + | deleteErr error | |
| 698 | 208 | } | |
| 699 | 209 | ||
| 700 | 210 | type fakeBlobStorage struct { | |
| 701 | 211 | out.BlobStorage | |
| 702 | 212 | ||
| 703 | - | blobs []string | |
| 704 | - | blobSizes map[string]int64 | |
| 705 | - | blobModTimes map[string]time.Time | |
| 706 | - | deletedBlobs []string | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | type fakeCleanupBlobStorage struct { | |
| 710 | - | *fakeBlobStorage | |
| 711 | - | uploadsRemoved int | |
| 712 | - | uploadBytes int64 | |
| 713 | - | capturedMaxAge time.Duration | |
| 714 | - | } | |
| 715 | - | ||
| 716 | - | func (f *fakeCleanupBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) { | |
| 717 | - | f.capturedMaxAge = maxAge | |
| 718 | - | return f.uploadsRemoved, f.uploadBytes, nil | |
| 213 | + | blobs []string | |
| 214 | + | blobSizes map[string]int64 | |
| 215 | + | blobModTimes map[string]time.Time | |
| 216 | + | blobModTimeErr error | |
| 217 | + | deletedBlobs []string | |
| 719 | 218 | } | |
| 720 | 219 | ||
| 721 | 220 | func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) { | |
| 722 | 221 | return f.listDetails, f.listErr | |
| 723 | 222 | } | |
| 724 | 223 | ||
| 725 | - | func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) { | |
| 726 | - | f.pruneCalled = true | |
| 727 | - | f.pruneDanglingOnly = danglingOnly | |
| 728 | - | return f.pruneReport, f.pruneErr | |
| 729 | - | } | |
| 730 | - | ||
| 731 | 224 | func newFakeManifestStorage() *fakeManifestStorage { | |
| 732 | 225 | return &fakeManifestStorage{ | |
| 733 | 226 | tagsByRepo: make(map[string][]string), | |
| @@ -804,6 +297,9 @@ func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.T | |||
| 804 | 297 | } | |
| 805 | 298 | ||
| 806 | 299 | func (f *fakeManifestStorage) DeleteManifest(name, reference string) error { | |
| 300 | + | if f.deleteErr != nil { | |
| 301 | + | return f.deleteErr | |
| 302 | + | } | |
| 807 | 303 | f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference}) | |
| 808 | 304 | tags := f.tagsByRepo[name] | |
| 809 | 305 | filtered := tags[:0] | |
| @@ -830,6 +326,9 @@ func (f *fakeBlobStorage) ListBlobs() ([]string, error) { | |||
| 830 | 326 | } | |
| 831 | 327 | ||
| 832 | 328 | func (f *fakeBlobStorage) GetBlobModTime(digest string) (time.Time, error) { | |
| 329 | + | if f.blobModTimeErr != nil { | |
| 330 | + | return time.Time{}, f.blobModTimeErr | |
| 331 | + | } | |
| 833 | 332 | return f.blobModTimes[digest], nil | |
| 834 | 333 | } | |
| 835 | 334 | ||