feat: Gordon v2.50.0 application deployment model (#276)
* feat(apps): add declarative application deployments * fix(security): enforce host trust boundaries * fix(security): close remaining host trust gaps * test(security): align hardened image fixtures * refactor(cli): route local commands through admin socket * fix(admin): authorize local control plane routes * feat(install): build next channel from pinned source * feat(install): default to user-local binary path * fix(ci): avoid overlapping Go module caches * fix(apps): address wrap-up review findings * fix(apps): close v2.50 deferred findings (#280) * fix(apps): persist mutation identity and close state cleanly * refactor(cli): unify app control plane capabilities * fix(apps): claim mutation keys atomically Add a durable request identity to app operation journals and one atomic store claim that binds an idempotency key to exactly one request: - domain.AppOperation carries the immutable request identity (kind, app, requested revision, requested service). - out.AppState.ClaimOperation persists the in-flight journal for an absent key, replays the stored journal for the same request, and refuses a key that already answered a different request with ErrAppStateConflict. - A claim for a name with no live app identity fails with the new ErrAppNotFound and writes nothing; the admin API maps it to 404 app-not-found. read-only AppExists backs the not-found normalization for revisions. - Every keyed deploy/stop/start/restart/remove claims before any effect. A replayed key returns its stored journal and runs no workload call; an unfinished journal returns the stored record with a conflict. - The apps use case returns the journaled operation together with the engine error instead of dropping the record. * fix(apps): order operation steps behind publication and complete read models Journal ordering: - A deploy service step is recorded as succeeded only after the runtime effect, the ACTIVE publication, and the traffic graph apply succeed; a rejected apply fails the step instead of leaving a success behind. - Lifecycle verbs record a failed traffic.publish step and a failed outcome when the final graph apply is rejected, so a rejected publication never replays as success. - Every error path after the claim now records a terminal failure, so a keyed repeat replays the failure instead of reading a stuck claim. - start marks a service step succeeded only after its effective state is published. Read models: - Show returns not-found for a name with no live app identity, and reports desired acceptance/pending status, per-service effective revision, container, digest, restart safety, owned resources (volumes, secret paths, image references), and the latest operation with kind, outcome, and start time. - List skips retired names and reports desired status, pending, convergence, stop intent, and the last outcome. Pending is derived from desired vs ACTIVE, never from a journal. - Admin DTO mappings carry these fields; deploy responses attach current effective and owned state instead of inferring convergence from the journal outcome. The unused observed field is gone. * fix(app): publish the host index only after the graph apply succeeds Traffic publication now prepares and commits instead of mutating the live host index first: - ACTIVE state projects into a detached candidate index that the graph is built and applied against. The live index is swapped only after the dataplane accepted the graph, so a rejected apply leaves both the index and the graph at the previous generation. - A failing projection applies nothing at all. - Withdrawal stays fail-closed: when the graph apply fails after the durable binds were cleared, the read model still drops the withdrawn host so the proxy never dials a recycled loopback port; the error is returned unchanged so the caller keeps its publication inhibition. HostIndex gains an Entries accessor for the swap, and the publisher takes its graph applier as a wired dependency so the ordering is testable without a live traffic manager. * fix(runtime): retire exact containers with the effective stop grace Runtime contract: - StopContainer and RestartContainer take the effective stop grace. The Docker adapter converts it to whole API seconds, rounding up, and keeps the runtime default for a non-positive grace instead of an immediate kill. The fixed twenty-second timeout is gone. - Every app path passes the grace of the generation it stops: stop, restart, removal, replacement retirement, readiness-failure cleanup, and recovery cleanup. A missing declared grace falls back to the app default, never to zero. One retirement path: - retireContainer stops with grace (or force-removes a never-published candidate), confirms the exact ID is gone, and only then releases the container's backend claims and clears its recovery inhibition. A container that is not confirmed gone keeps its claims, its inhibition, and its ACTIVE reference, and fails the operation. - Stop, remove, deploy service removal, HTTP replacement retirement, interrupted replacement, readiness-failure candidate cleanup, create and start cleanup, backend-bind registration failure, and container-not-found recovery all use it, so no disappearance path leaks a loopback claim. - A confirmed-missing container releases its claims during recovery. Bounded leftovers: - Journaled operations carry bounded cleanup warnings (service, leftover, detail) for anything that could not be removed or released; they reach the admin response instead of being dropped, and never contain logs. * fix(apps): reclaim private app networks on removal Removal now removes the app's incarnation-owned private networks immediately, once every exact container is confirmed gone and while the ownership record is still live: - The runtime name is re-derived from the incarnation UUID and the observed labels must prove that exact ownership, so a name reused by a later incarnation can never delete the previous one's network. - A network with attached containers, or with labels that do not prove this incarnation, is left in place and reported as a bounded warning. - A failed removal aborts the operation before the incarnation is retired, so the stopped intent, the inhibition, and ACTIVE state stay and a retry can converge. - Shared networks are never removed by an app removal, and retained volumes, secrets, and images are untouched. * refactor(backup): resolve app backups from manifest declarations Backup targets are now read from the app's ACTIVE record instead of being inferred, and the app name is the backup identity: - A target exists because a service declares it: a PostgreSQL database in service.databases referenced by service.backup.postgres, or a volume in service.volumes referenced by service.backup.volume. The image, port, container label, and attachment heuristics are gone, along with the detection endpoint and DTOs that exposed them. - Selectors are explicit: an omitted --service or resource selector only succeeds when exactly one compatible target exists, ambiguity lists the candidate service/resource names, and an unknown target is not found. - The scheduler runs only databases whose own declared schedule matches the firing tier, and volume runs follow the installation preset. - Artifacts are stored under the app name: filesystem paths and S3 keys use apps/<app>/..., never a domain prefix. Existing domain-keyed artifacts are left untouched and are not read. - The admin API and CLI rename domain fields to app, add service, and name the resource database or volume. The CLI gains `backup run APP --service S --database D` and `backup volume run APP --service S --volume V`; the domain-keyed `detect` command and the databases/volumes aliases are gone. * refactor(cli): unify the app and daemon control planes The CLI now has one ControlPlane interface and one daemon-backed implementation: - App methods moved into ControlPlane; AppControlPlane, remoteAppControlPlane, and app_controlplane.go are gone, and remoteControlPlane delegates every operation to a single remote.Client (explicit remote or owner-only local admin socket). - App commands resolve through resolveAppPlane, which returns a handle they close, instead of a separate resolver with no ownership. - CLI tests drive the generated MockControlPlane: the handwritten fakeAppPlane and status shim are gone, and the source-text parity and UI adoption checks are replaced by the behavior they guarded (local socket and explicit-remote resolution, DTO parity across transports, presentation helpers). * refactor: drop dead app plumbing and test-only production hooks - AppServiceImpl keeps one configured core apps Service instead of rebuilding it on every apply; the listener, barrier, and image-policy options configure that instance in place. - Remove the container cache/drain/metrics hooks that only stored their dependencies, the proxy container-deployed invalidation handler and its event payload, the container event constants nothing published or read, the internal-deploy context flag with no reader, the label constants the declarative cutover stopped using, and the deprecated revision retention alias. - Move the app-state corruption hooks out of the production store into export_test.go. * docs: describe current backup, log, and app identity behavior - Backup docs document declarative targets (app, service, database or volume), the explicit selector rules, app-keyed artifact paths, and the app-based JSON fields. - App docs describe the idempotency contract, the complete show read model, and app/service-only log identity. - `gordon logs` reads daemon process logs only: the domain-keyed container-log path and its control-plane methods are gone, and app workload output is read through `gordon apps logs`. * fix(apps): close deferred review gaps
- commit
- 25270e4106ff3d0ed99d9afd3c588dcb9bb31553
- author
- bnema · · 2026-09-12 14:48 UTC
- committer
- GitHub · · 2026-09-12 14:48 UTC
- parent
- da9b3d2
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
439 files · +44645 -50814
M .github/workflows/ci.yml +19 -24
| @@ -13,6 +13,7 @@ jobs: | |||
| 13 | 13 | pull-requests: read | |
| 14 | 14 | outputs: | |
| 15 | 15 | go: ${{ steps.filter.outputs.go }} | |
| 16 | + | installer: ${{ steps.filter.outputs.installer }} | |
| 16 | 17 | steps: | |
| 17 | 18 | - uses: actions/checkout@v6 | |
| 18 | 19 | - uses: dorny/paths-filter@v3 | |
| @@ -26,6 +27,22 @@ jobs: | |||
| 26 | 27 | - '.golangci.yml' | |
| 27 | 28 | - 'Makefile' | |
| 28 | 29 | - '.github/workflows/**' | |
| 30 | + | installer: | |
| 31 | + | - 'install.sh' | |
| 32 | + | - 'tests/install_next_test.sh' | |
| 33 | + | - '.github/workflows/ci.yml' | |
| 34 | + | ||
| 35 | + | installer: | |
| 36 | + | name: Installer | |
| 37 | + | needs: changes | |
| 38 | + | if: needs.changes.outputs.installer == 'true' | |
| 39 | + | runs-on: ubuntu-latest | |
| 40 | + | steps: | |
| 41 | + | - uses: actions/checkout@v6 | |
| 42 | + | - name: Check installer scripts | |
| 43 | + | run: | | |
| 44 | + | shellcheck -s sh install.sh tests/install_next_test.sh | |
| 45 | + | sh tests/install_next_test.sh | |
| 29 | 46 | ||
| 30 | 47 | lint: | |
| 31 | 48 | name: Lint | |
| @@ -38,14 +55,7 @@ jobs: | |||
| 38 | 55 | - uses: actions/setup-go@v5 | |
| 39 | 56 | with: | |
| 40 | 57 | go-version: "1.27.x" | |
| 41 | - | ||
| 42 | - | - name: Cache Go modules | |
| 43 | - | uses: actions/cache@v5 | |
| 44 | - | with: | |
| 45 | - | path: ~/go/pkg/mod | |
| 46 | - | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} | |
| 47 | - | restore-keys: | | |
| 48 | - | ${{ runner.os }}-go-mod- | |
| 58 | + | cache: true | |
| 49 | 59 | ||
| 50 | 60 | - name: Run golangci-lint | |
| 51 | 61 | uses: golangci/golangci-lint-action@v9 | |
| @@ -63,22 +73,7 @@ jobs: | |||
| 63 | 73 | - uses: actions/setup-go@v5 | |
| 64 | 74 | with: | |
| 65 | 75 | go-version: "1.27.x" | |
| 66 | - | ||
| 67 | - | - name: Cache Go modules | |
| 68 | - | uses: actions/cache@v5 | |
| 69 | - | with: | |
| 70 | - | path: ~/go/pkg/mod | |
| 71 | - | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} | |
| 72 | - | restore-keys: | | |
| 73 | - | ${{ runner.os }}-go-mod- | |
| 74 | - | ||
| 75 | - | - name: Cache Go build cache | |
| 76 | - | uses: actions/cache@v5 | |
| 77 | - | with: | |
| 78 | - | path: ~/.cache/go-build | |
| 79 | - | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 80 | - | restore-keys: | | |
| 81 | - | ${{ runner.os }}-go-build- | |
| 76 | + | cache: true | |
| 82 | 77 | ||
| 83 | 78 | - name: Run tests | |
| 84 | 79 | run: go test -race -v ./... | |
M .mockery.yaml +11 -3
| @@ -19,18 +19,25 @@ packages: | |||
| 19 | 19 | SecretProvider: | |
| 20 | 20 | EnvLoader: | |
| 21 | 21 | ContainerLogWriter: | |
| 22 | - | AttachmentConfigProvider: | |
| 23 | 22 | TokenStore: | |
| 23 | + | AppState: | |
| 24 | + | AppStateReader: | |
| 25 | + | ImageResolver: | |
| 26 | + | SecretWriter: | |
| 24 | 27 | DomainSecretStore: | |
| 25 | 28 | RateLimiter: | |
| 26 | 29 | BackupStorage: | |
| 27 | - | RouteChecker: | |
| 28 | 30 | HTTPChallengeSink: | |
| 29 | 31 | PublicCertificateIssuer: | |
| 30 | 32 | CertificateStore: | |
| 31 | 33 | SecretResolver: | |
| 32 | 34 | CloudflareZoneResolver: | |
| 33 | 35 | CertificateAuthority: | |
| 36 | + | AppTrafficRefresher: | |
| 37 | + | PruneProtectionStore: | |
| 38 | + | PruneRuntime: | |
| 39 | + | GCBarrier: | |
| 40 | + | GCLease: | |
| 34 | 41 | github.com/bnema/gordon/internal/boundaries/in: | |
| 35 | 42 | interfaces: | |
| 36 | 43 | ContainerService: | |
| @@ -47,7 +54,8 @@ packages: | |||
| 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 README.md +73 -45
| @@ -2,7 +2,7 @@ | |||
| 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 | 8 | - Documentation: [Docs](https://gordon.bnema.dev/docs) | [Wiki](https://gordon.bnema.dev/wiki) | |
| @@ -12,47 +12,70 @@ Self-hosted container deployment. Push an image, Gordon routes it to the web. | |||
| 12 | 12 | ||
| 13 | 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 | |
| 22 | + | # Install the latest stable release to ~/.local/bin | |
| 21 | 23 | curl -fsSL https://gordon.bnema.dev/install.sh | 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 | ||
| 29 | + | The installer uses `~/.local/bin` without `sudo` and can add the effective install directory to Fish, Bash, or Zsh PATH configuration. Set `GORDON_UPDATE_PATH=1` to update PATH without prompting or `GORDON_UPDATE_PATH=0` to leave configuration unchanged. Override the destination with an absolute path such as `GORDON_INSTALL_DIR="$HOME/bin"`, `GORDON_INSTALL_DIR="$HOME/.local/bin"`, or `GORDON_INSTALL_DIR=/usr/local/bin`. | |
| 30 | + | ||
| 31 | + | To build the current `next` branch commit locally, use `GORDON_CHANNEL=next`. This is an unverified development source build, not a checksum-verified release, and requires a compatible Go toolchain. See the [installation guide](https://gordon.bnema.dev/docs/installation#choosing-an-install-channel). | |
| 32 | + | ||
| 27 | 33 | Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for full setup. | |
| 28 | 34 | ||
| 29 | 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 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 | 50 | gordon status | |
| 51 | + | gordon apps status blog --remote prod | |
| 43 | 52 | ``` | |
| 44 | 53 | ||
| 45 | - | Roll back, restart, or manage secrets — all from the command line: | |
| 54 | + | Minimal app file (`blog.toml`): | |
| 55 | + | ||
| 56 | + | ```toml | |
| 57 | + | name = "blog" | |
| 58 | + | ||
| 59 | + | [[service]] | |
| 60 | + | name = "web" | |
| 61 | + | image = "gordon.mydomain.com/myapp:v1.2.0" | |
| 62 | + | ||
| 63 | + | [[service.http]] | |
| 64 | + | host = "blog.mydomain.com" | |
| 65 | + | port = 3000 | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | Manage lifecycle and secrets — all from the command line: | |
| 46 | 69 | ||
| 47 | 70 | ```bash | |
| 48 | - | gordon pin app.example.com # Pin to a specific image tag | |
| 49 | - | gordon restart app.example.com # Restart the container | |
| 50 | - | gordon secrets set app.example.com DB_HOST=db.internal API_KEY=secret123 | |
| 71 | + | gordon apps restart blog --remote prod # Restart from pinned digests | |
| 72 | + | gordon apps stop blog --remote prod # Stop, preserve all data | |
| 73 | + | gordon apps secrets set blog --service web DATABASE_URL=... --remote prod | |
| 51 | 74 | ``` | |
| 52 | 75 | ||
| 53 | 76 | ## Deploy from CI/CD | |
| 54 | 77 | ||
| 55 | - | Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on image push. | |
| 78 | + | Push to Gordon's registry from any CI pipeline, then apply and deploy with the CLI. Push never triggers a deploy by itself. | |
| 56 | 79 | ||
| 57 | 80 | ### GitHub Actions | |
| 58 | 81 | ||
| @@ -64,17 +87,19 @@ Push to Gordon's registry from any CI pipeline. Gordon deploys automatically on | |||
| 64 | 87 | password: ${{ secrets.GORDON_TOKEN }} | |
| 65 | 88 | ``` | |
| 66 | 89 | ||
| 67 | - | ### Docker CLI | |
| 90 | + | See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options. | |
| 91 | + | ||
| 92 | + | ### Docker CLI + Gordon CLI | |
| 68 | 93 | ||
| 69 | 94 | ```bash | |
| 70 | - | docker login registry.mydomain.com | |
| 71 | - | docker build -t registry.mydomain.com/myapp:v1.0.0 . | |
| 72 | - | docker push registry.mydomain.com/myapp:v1.0.0 | |
| 73 | - | # -> Deployed automatically | |
| 95 | + | docker login gordon.mydomain.com | |
| 96 | + | docker build -t gordon.mydomain.com/myapp:v1.0.0 . | |
| 97 | + | docker push gordon.mydomain.com/myapp:v1.0.0 | |
| 98 | + | # Then, with the Gordon binary: | |
| 99 | + | gordon apps apply --file blog.toml --remote prod | |
| 100 | + | gordon apps deploy blog --remote prod | |
| 74 | 101 | ``` | |
| 75 | 102 | ||
| 76 | - | See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platform builds, monorepo support, and all available options. | |
| 77 | - | ||
| 78 | 103 | ## CLI Commands | |
| 79 | 104 | ||
| 80 | 105 | ### Server | |
| @@ -82,20 +107,24 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 82 | 107 | | Command | Description | | |
| 83 | 108 | |---------|-------------| | |
| 84 | 109 | | `gordon serve` | Start the Gordon server | | |
| 85 | - | | `gordon status` | Show server and route health | | |
| 86 | - | | `gordon config show` | Display server configuration | | |
| 110 | + | | `gordon status` | Show server and app fleet status | | |
| 111 | + | | `gordon config show` | Display installation configuration | | |
| 87 | 112 | ||
| 88 | - | ### Deployment | |
| 113 | + | ### Applications | |
| 89 | 114 | ||
| 90 | 115 | | Command | Description | | |
| 91 | 116 | |---------|-------------| | |
| 92 | - | | `gordon push [image]` | Tag, push, and optionally deploy an image | | |
| 93 | - | | `gordon routes list` | List all routes | | |
| 94 | - | | `gordon routes add <domain> <image>` | Create or update a route | | |
| 95 | - | | `gordon routes remove <domain>` | Remove a route | | |
| 96 | - | | `gordon routes deploy <domain>` | Redeploy a route | | |
| 97 | - | | `gordon pin <domain>` | Pin a route to a specific image tag | | |
| 98 | - | | `gordon restart <domain>` | Restart a route container | | |
| 117 | + | | `gordon apps apply --file FILE` | Validate and persist an app manifest | | |
| 118 | + | | `gordon apps deploy APP` | Activate an app revision | | |
| 119 | + | | `gordon apps list` | List applications | | |
| 120 | + | | `gordon apps show APP` | Show desired and active state | | |
| 121 | + | | `gordon apps status APP` | Show effective vs observed state | | |
| 122 | + | | `gordon apps logs APP` | Show logs for an app service | | |
| 123 | + | | `gordon apps restart APP` | Restart from pinned digests | | |
| 124 | + | | `gordon apps stop APP` | Stop, preserve all data | | |
| 125 | + | | `gordon apps start APP` | Start a stopped app | | |
| 126 | + | | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) | | |
| 127 | + | | `gordon push [image]` | Tag and push an image (never deploys) | | |
| 99 | 128 | ||
| 100 | 129 | ### Images & Registry | |
| 101 | 130 | ||
| @@ -109,9 +138,9 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 109 | 138 | ||
| 110 | 139 | | Command | Description | | |
| 111 | 140 | |---------|-------------| | |
| 112 | - | | `gordon secrets list <domain>` | List secrets for a route | | |
| 113 | - | | `gordon secrets set <domain> KEY=VAL` | Set secrets | | |
| 114 | - | | `gordon secrets remove <domain> <key>` | Remove a secret | | |
| 141 | + | | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values | | |
| 142 | + | | `gordon secrets list <domain>` | List installation secret keys | | |
| 143 | + | | `gordon secrets set <domain> --from-file PATH` | Set installation secrets | | |
| 115 | 144 | ||
| 116 | 145 | ### Remotes & Auth | |
| 117 | 146 | ||
| @@ -125,19 +154,18 @@ 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 | + | - HTTP zero-downtime updates (old container serves until replacement passes readiness) | |
| 161 | + | - Remote CLI management (daemon is the sole writer) | |
| 162 | + | - Declarative per-service volumes, retained across lifecycle operations | |
| 163 | + | - Per-service secrets in pass, app-wide public env in the manifest | |
| 164 | + | - Per-app private networks plus opt-in shared networks | |
| 165 | + | - Single binary | |
| 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 | ||
A docs/cli/apps.md +213 -0
| @@ -0,0 +1,213 @@ | |||
| 1 | + | # Apps Commands | |
| 2 | + | ||
| 3 | + | Validate, persist, inspect, and operate applications. | |
| 4 | + | ||
| 5 | + | All mutations are executed by the daemon through the admin API. | |
| 6 | + | Without a reachable daemon the commands fail with `daemon-unavailable` | |
| 7 | + | instead of writing locally. Mutations are idempotent: every request carries a | |
| 8 | + | client-generated key, and the daemon binds that key to the exact request. | |
| 9 | + | Repeating a key replays the recorded result; reusing it for a different request | |
| 10 | + | is refused. An interrupted operation is never executed twice, so an ambiguous | |
| 11 | + | outcome is re-queried by key before any retry (never retry under a fresh | |
| 12 | + | key). Unknown apps are reported as `app not found` and create no state. | |
| 13 | + | ||
| 14 | + | ## Local and remote targets | |
| 15 | + | ||
| 16 | + | With no `--remote`/`GORDON_REMOTE` selected, commands discover the daemon's | |
| 17 | + | owner-only administration socket. The CLI checks `$XDG_RUNTIME_DIR/gordon/admin.sock` | |
| 18 | + | when set, then `/run/user/<uid>/gordon/admin.sock`, then `~/.gordon/run/admin.sock`. | |
| 19 | + | It accepts only a safe owner-owned socket. This works with `auth.enabled=false`: | |
| 20 | + | the socket carries no bearer token and grants the `local-owner` principal only | |
| 21 | + | app administration and app log reads. An explicit remote is authoritative and | |
| 22 | + | never falls back to the socket. | |
| 23 | + | See [Local-only Mode](../config/auth.md#local-only-mode). | |
| 24 | + | ||
| 25 | + | ## gordon apps | |
| 26 | + | ||
| 27 | + | ### Subcommands | |
| 28 | + | ||
| 29 | + | | Subcommand | Description | | |
| 30 | + | |------------|-------------| | |
| 31 | + | | `apply` | Validate and persist an app manifest | | |
| 32 | + | | `list` | List applications | | |
| 33 | + | | `show` | Show desired and active state for an app | | |
| 34 | + | | `diff` | Show the normalized desired-vs-active diff | | |
| 35 | + | | `secrets` | Manage app secret values | | |
| 36 | + | | `deploy` | Activate an app revision | | |
| 37 | + | | `restart` | Restart an app from pinned digests | | |
| 38 | + | | `stop` | Stop an app (preserves all data) | | |
| 39 | + | | `start` | Start a stopped app from active state | | |
| 40 | + | | `remove` | Remove app workloads (volumes and secrets are retained) | | |
| 41 | + | | `status` | Show effective vs observed state for an app | | |
| 42 | + | | `logs` | Show logs for an app service | | |
| 43 | + | ||
| 44 | + | --- | |
| 45 | + | ||
| 46 | + | ## gordon apps apply | |
| 47 | + | ||
| 48 | + | ```bash | |
| 49 | + | gordon apps apply --file blog.toml [--dry-run] [--deploy] [--json] | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | Validates a manifest file and persists it as desired state (or dry-runs). | |
| 53 | + | With `--deploy`, chains exactly the accepted revision into a deploy after | |
| 54 | + | persistence succeeds; the two outcomes are reported separately because a | |
| 55 | + | deploy may fail after the apply succeeded. | |
| 56 | + | ||
| 57 | + | `--dry-run` and `--deploy` cannot be combined. | |
| 58 | + | ||
| 59 | + | --- | |
| 60 | + | ||
| 61 | + | ## gordon apps list | |
| 62 | + | ||
| 63 | + | ```bash | |
| 64 | + | gordon apps list [--json] | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | Lists applications with one row per app. States are `applied` when no revision is active, `pending` when desired state awaits activation, `stopped` when stopped intent is set, and otherwise `active`. A non-success last operation is appended to `active` in parentheses. | |
| 68 | + | ||
| 69 | + | --- | |
| 70 | + | ||
| 71 | + | ## gordon apps show | |
| 72 | + | ||
| 73 | + | ```bash | |
| 74 | + | gordon apps show APP [--json] | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | Shows desired revision and acceptance status, pending state, per-service | |
| 78 | + | effective revision, container, digest, and restart safety, the resources the | |
| 79 | + | app owns (volumes, secret paths, image references — never secret values), | |
| 80 | + | stopped intent, and the last operation with its outcome. | |
| 81 | + | ||
| 82 | + | --- | |
| 83 | + | ||
| 84 | + | ## gordon apps diff | |
| 85 | + | ||
| 86 | + | ```bash | |
| 87 | + | gordon apps diff APP [--json] | |
| 88 | + | ``` | |
| 89 | + | ||
| 90 | + | Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`). | |
| 91 | + | ||
| 92 | + | --- | |
| 93 | + | ||
| 94 | + | ## gordon apps secrets | |
| 95 | + | ||
| 96 | + | Values are accepted via `KEY=VALUE` arguments (discouraged: shell history), | |
| 97 | + | `--stdin` (preferred), or an interactive prompt. Names must already exist in | |
| 98 | + | desired or active state. Only key names are ever echoed back — never values. | |
| 99 | + | ||
| 100 | + | ```bash | |
| 101 | + | gordon apps secrets set APP --service SVC KEY=VALUE… [--stdin] [--json] | |
| 102 | + | gordon apps secrets delete APP KEY --service SVC [--json] | |
| 103 | + | ``` | |
| 104 | + | ||
| 105 | + | `--service` is required: secrets are service-scoped. | |
| 106 | + | ||
| 107 | + | --- | |
| 108 | + | ||
| 109 | + | ## gordon apps deploy | |
| 110 | + | ||
| 111 | + | ```bash | |
| 112 | + | gordon apps deploy APP [--revision REV] [--service SVC] [--json] | |
| 113 | + | ``` | |
| 114 | + | ||
| 115 | + | Activates a revision (default: desired head). Fail-fast across services: | |
| 116 | + | the first failure stops the deploy, successful services are preserved, | |
| 117 | + | later services stay unchanged. | |
| 118 | + | ||
| 119 | + | --- | |
| 120 | + | ||
| 121 | + | ## gordon apps restart | |
| 122 | + | ||
| 123 | + | ```bash | |
| 124 | + | gordon apps restart APP [--service SVC] [--json] | |
| 125 | + | ``` | |
| 126 | + | ||
| 127 | + | Restarts from pinned digests without re-resolution. | |
| 128 | + | ||
| 129 | + | --- | |
| 130 | + | ||
| 131 | + | ## gordon apps stop | |
| 132 | + | ||
| 133 | + | ```bash | |
| 134 | + | gordon apps stop APP [--json] | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | Persists the durable stopped intent and stops exact containers. | |
| 138 | + | All data (volumes, secrets) is preserved. Stopped apps stay stopped | |
| 139 | + | across reboot. | |
| 140 | + | ||
| 141 | + | --- | |
| 142 | + | ||
| 143 | + | ## gordon apps start | |
| 144 | + | ||
| 145 | + | ```bash | |
| 146 | + | gordon apps start APP [--json] | |
| 147 | + | ``` | |
| 148 | + | ||
| 149 | + | Clears the stopped intent and ensures running from active state. | |
| 150 | + | ||
| 151 | + | --- | |
| 152 | + | ||
| 153 | + | ## gordon apps remove | |
| 154 | + | ||
| 155 | + | ```bash | |
| 156 | + | gordon apps remove APP [--json] | |
| 157 | + | ``` | |
| 158 | + | ||
| 159 | + | Withdraws workloads. Volumes and secrets are retained as owned orphans | |
| 160 | + | under the old internal UUID; removing frees the name but never implicitly | |
| 161 | + | attaches retained resources to a new app reusing the name. | |
| 162 | + | ||
| 163 | + | There is deliberately no `purge`: destructive volume deletion requires a | |
| 164 | + | separately accepted destructive-action contract. | |
| 165 | + | ||
| 166 | + | --- | |
| 167 | + | ||
| 168 | + | ## gordon apps status | |
| 169 | + | ||
| 170 | + | ```bash | |
| 171 | + | gordon apps status APP [--json] | |
| 172 | + | ``` | |
| 173 | + | ||
| 174 | + | Shows effective vs observed state per service. | |
| 175 | + | ||
| 176 | + | --- | |
| 177 | + | ||
| 178 | + | ## gordon apps logs | |
| 179 | + | ||
| 180 | + | ```bash | |
| 181 | + | gordon apps logs APP [--service SVC] [--follow] [--tail N] [--json] | |
| 182 | + | ``` | |
| 183 | + | ||
| 184 | + | Streams logs for a service in the app's active deployment. The app name and | |
| 185 | + | `--service` are the only accepted identity: domains and raw container IDs are | |
| 186 | + | never accepted. `--service` is required when the app has several services. | |
| 187 | + | ||
| 188 | + | --- | |
| 189 | + | ||
| 190 | + | ## Workflow Example | |
| 191 | + | ||
| 192 | + | ```bash | |
| 193 | + | # Push the image first (OCI transfer only) | |
| 194 | + | gordon push myapp --build --remote prod | |
| 195 | + | ||
| 196 | + | # Apply the manifest that references the pushed tag | |
| 197 | + | gordon apps apply --file blog.toml --remote prod | |
| 198 | + | ||
| 199 | + | # Write secret values for registered names | |
| 200 | + | gordon apps secrets set blog --service web DATABASE_URL=... --remote prod | |
| 201 | + | ||
| 202 | + | # Deploy the accepted revision | |
| 203 | + | gordon apps deploy blog --remote prod | |
| 204 | + | ||
| 205 | + | # Inspect | |
| 206 | + | gordon apps show blog --remote prod | |
| 207 | + | gordon apps status blog --remote prod | |
| 208 | + | ``` | |
| 209 | + | ||
| 210 | + | ## Related | |
| 211 | + | ||
| 212 | + | - [CLI Overview](./index.md) | |
| 213 | + | - [Push Command](./push.md) | |
D docs/cli/attachments.md +0 -349
| @@ -1,349 +0,0 @@ | |||
| 1 | - | # Attachments Commands | |
| 2 | - | ||
| 3 | - | Manage attachments on local or remote Gordon instances. | |
| 4 | - | ||
| 5 | - | Remote targeting uses client config or an active remote by default. | |
| 6 | - | When you provide a concrete target (for example `attachments list app.example.com` | |
| 7 | - | or `attachments remove app.example.com postgres:18`) and no remote is selected, | |
| 8 | - | Gordon can also auto-infer a saved remote when exactly one match is found. | |
| 9 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 10 | - | ||
| 11 | - | ## Requirements | |
| 12 | - | ||
| 13 | - | Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |
| 14 | - | ||
| 15 | - | The CLI will warn you if you try to add an attachment without network isolation enabled. | |
| 16 | - | ||
| 17 | - | ## gordon attachments | |
| 18 | - | ||
| 19 | - | ### Subcommands | |
| 20 | - | ||
| 21 | - | | Subcommand | Description | | |
| 22 | - | |------------|-------------| | |
| 23 | - | | `list` | List all attachments or attachments for a specific target | | |
| 24 | - | | `add` | Add an attachment to a domain or network group | | |
| 25 | - | | `push` | Build or push attachment images to the Gordon registry | | |
| 26 | - | | `remove` | Remove an attachment from a domain or network group | | |
| 27 | - | | `orphans` | List running attachment containers no longer configured | | |
| 28 | - | | `prune` | Dry-run or stop orphaned attachment containers while preserving volumes | | |
| 29 | - | ||
| 30 | - | ### Alias | |
| 31 | - | ||
| 32 | - | `gordon attach` is an alias for `gordon attachments`: | |
| 33 | - | ||
| 34 | - | ```bash | |
| 35 | - | gordon attach list | |
| 36 | - | gordon attach add app.example.com postgres:18 | |
| 37 | - | gordon attach remove app.example.com postgres:18 | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | --- | |
| 41 | - | ||
| 42 | - | ## gordon attachments list | |
| 43 | - | ||
| 44 | - | List all configured attachments. | |
| 45 | - | ||
| 46 | - | ```bash | |
| 47 | - | # List all attachments | |
| 48 | - | gordon attachments list | |
| 49 | - | gordon attachments list --json | |
| 50 | - | ||
| 51 | - | # List attachments for a specific domain or group | |
| 52 | - | gordon attachments list app.example.com | |
| 53 | - | gordon attachments list backend | |
| 54 | - | ||
| 55 | - | # Remote (override) | |
| 56 | - | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | |
| 57 | - | ``` | |
| 58 | - | ||
| 59 | - | ### Output | |
| 60 | - | ||
| 61 | - | ``` | |
| 62 | - | Target Attachments | |
| 63 | - | -------------------------------------------------------------------------------- | |
| 64 | - | app.example.com postgres:18, redis:7-alpine | |
| 65 | - | api.example.com postgres:18 | |
| 66 | - | backend (group) rabbitmq:3-management | |
| 67 | - | ``` | |
| 68 | - | ||
| 69 | - | ### JSON Output | |
| 70 | - | ||
| 71 | - | ```bash | |
| 72 | - | gordon attachments list --json | |
| 73 | - | ``` | |
| 74 | - | ||
| 75 | - | ```json | |
| 76 | - | [ | |
| 77 | - | { | |
| 78 | - | "target": "app.example.com", | |
| 79 | - | "attachments": ["postgres:18", "redis:7-alpine"] | |
| 80 | - | }, | |
| 81 | - | { | |
| 82 | - | "target": "backend", | |
| 83 | - | "attachments": ["rabbitmq:3-management"] | |
| 84 | - | } | |
| 85 | - | ] | |
| 86 | - | ``` | |
| 87 | - | ||
| 88 | - | ### Options | |
| 89 | - | ||
| 90 | - | | Option | Description | | |
| 91 | - | |--------|-------------| | |
| 92 | - | | `--json` | Output attachments as JSON | | |
| 93 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 94 | - | | `--token` | Authentication token for remote | | |
| 95 | - | ||
| 96 | - | --- | |
| 97 | - | ||
| 98 | - | ## gordon attachments orphans | |
| 99 | - | ||
| 100 | - | List running attachment containers that are no longer configured. | |
| 101 | - | ||
| 102 | - | ```bash | |
| 103 | - | gordon attachments orphans | |
| 104 | - | gordon attachments orphans --json | |
| 105 | - | ``` | |
| 106 | - | ||
| 107 | - | ### Output | |
| 108 | - | ||
| 109 | - | | Container ID | Name | Image | Status | Owner | | |
| 110 | - | |--------------|------|-------|--------|-------| | |
| 111 | - | | `pg123` | `postgres` | `postgres:16` | `running` | `myapp.example.com` | | |
| 112 | - | ||
| 113 | - | ### JSON Output | |
| 114 | - | ||
| 115 | - | ```json | |
| 116 | - | { | |
| 117 | - | "attachments": [ | |
| 118 | - | { | |
| 119 | - | "container_id": "pg123", | |
| 120 | - | "name": "postgres", | |
| 121 | - | "image": "postgres:16", | |
| 122 | - | "status": "running", | |
| 123 | - | "owner": "myapp.example.com" | |
| 124 | - | } | |
| 125 | - | ] | |
| 126 | - | } | |
| 127 | - | ``` | |
| 128 | - | ||
| 129 | - | ## gordon attachments prune | |
| 130 | - | ||
| 131 | - | Lists orphaned attachments by default (dry-run). Use `--stop` to stop and remove the orphaned containers while preserving attachment volumes/data. | |
| 132 | - | ||
| 133 | - | ```bash | |
| 134 | - | gordon attachments prune # dry-run/report only | |
| 135 | - | gordon attachments prune --stop # stop/remove orphaned containers, preserve volumes | |
| 136 | - | ``` | |
| 137 | - | ||
| 138 | - | ### Output | |
| 139 | - | ||
| 140 | - | ```text | |
| 141 | - | Orphaned attachments | |
| 142 | - | postgres -> postgres:16 | |
| 143 | - | Hint: orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes | |
| 144 | - | ``` | |
| 145 | - | ||
| 146 | - | ### Output with `--stop` | |
| 147 | - | ||
| 148 | - | ```text | |
| 149 | - | Removed orphaned attachment containers: | |
| 150 | - | postgres | |
| 151 | - | Hint: attachment volumes and data were preserved | |
| 152 | - | ``` | |
| 153 | - | ||
| 154 | - | ### Options | |
| 155 | - | ||
| 156 | - | | Option | Description | | |
| 157 | - | |--------|-------------| | |
| 158 | - | | `--stop` | Stop and remove orphaned attachment containers while preserving volumes/data | | |
| 159 | - | | `--json` | Output the cleanup report as JSON | | |
| 160 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 161 | - | | `--token` | Authentication token for remote | | |
| 162 | - | ||
| 163 | - | ## gordon attachments add | |
| 164 | - | ||
| 165 | - | Add an attachment to a domain or network group. | |
| 166 | - | ||
| 167 | - | ```bash | |
| 168 | - | gordon attachments add <domain-or-group> <image> | |
| 169 | - | gordon attachments add app.example.com postgres:18 | |
| 170 | - | gordon attachments add backend redis:7-alpine | |
| 171 | - | ``` | |
| 172 | - | ||
| 173 | - | ### Arguments | |
| 174 | - | ||
| 175 | - | | Argument | Description | | |
| 176 | - | |----------|-------------| | |
| 177 | - | | `<domain-or-group>` | The domain name or network group name | | |
| 178 | - | | `<image>` | The container image to attach | | |
| 179 | - | ||
| 180 | - | ### Options | |
| 181 | - | ||
| 182 | - | | Option | Description | | |
| 183 | - | |--------|-------------| | |
| 184 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 185 | - | | `--token` | Authentication token for remote | | |
| 186 | - | ||
| 187 | - | ### Examples | |
| 188 | - | ||
| 189 | - | ```bash | |
| 190 | - | # Add database to a domain | |
| 191 | - | gordon attachments add app.example.com postgres:18 | |
| 192 | - | ||
| 193 | - | # Add cache to a network group (shared by all domains in the group) | |
| 194 | - | gordon attachments add backend redis:7-alpine | |
| 195 | - | ||
| 196 | - | # Remote (override) | |
| 197 | - | gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 198 | - | ``` | |
| 199 | - | ||
| 200 | - | --- | |
| 201 | - | ||
| 202 | - | ## gordon attachments push | |
| 203 | - | ||
| 204 | - | Push attachment images to the Gordon registry. | |
| 205 | - | ||
| 206 | - | ```bash | |
| 207 | - | gordon attachments push <image> [options] | |
| 208 | - | ``` | |
| 209 | - | ||
| 210 | - | ### Arguments | |
| 211 | - | ||
| 212 | - | | Argument | Description | | |
| 213 | - | |----------|-------------| | |
| 214 | - | | `<image>` | The attachment image to build/tag and push | | |
| 215 | - | ||
| 216 | - | ### Options | |
| 217 | - | ||
| 218 | - | | Option | Description | | |
| 219 | - | |--------|-------------| | |
| 220 | - | | `--build` | Build the image first using `docker buildx` | | |
| 221 | - | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 222 | - | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 223 | - | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 224 | - | | `--tag` | Override version tag | | |
| 225 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 226 | - | | `--token` | Authentication token for remote | | |
| 227 | - | ||
| 228 | - | ### Description | |
| 229 | - | ||
| 230 | - | `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first. | |
| 231 | - | ||
| 232 | - | It uses the same native chunked upload transport as `gordon push`, sending image | |
| 233 | - | layers in 50MB chunks so pushes work through Cloudflare-proxied Gordon | |
| 234 | - | instances. Keep the server's `max_blob_chunk_size` larger than the client chunk | |
| 235 | - | size; the default `95MB` is compatible. | |
| 236 | - | ||
| 237 | - | ### Examples | |
| 238 | - | ||
| 239 | - | ```bash | |
| 240 | - | # Push a pre-built attachment image | |
| 241 | - | gordon attachments push pitlane-pgsql | |
| 242 | - | ||
| 243 | - | # Build and push an attachment image | |
| 244 | - | gordon attachments push pitlane-pgsql --build | |
| 245 | - | ||
| 246 | - | # Push with a specific tag | |
| 247 | - | gordon attachments push pitlane-pgsql --tag v18 | |
| 248 | - | ``` | |
| 249 | - | ||
| 250 | - | --- | |
| 251 | - | ||
| 252 | - | ## gordon attachments remove | |
| 253 | - | ||
| 254 | - | Remove an attachment. | |
| 255 | - | ||
| 256 | - | ```bash | |
| 257 | - | gordon attachments remove <domain-or-group> <image> | |
| 258 | - | gordon attachments remove app.example.com postgres:18 | |
| 259 | - | ``` | |
| 260 | - | ||
| 261 | - | ### Arguments | |
| 262 | - | ||
| 263 | - | | Argument | Description | | |
| 264 | - | |----------|-------------| | |
| 265 | - | | `<domain-or-group>` | The domain name or network group name | | |
| 266 | - | | `<image>` | The container image to remove | | |
| 267 | - | ||
| 268 | - | ### Options | |
| 269 | - | ||
| 270 | - | | Option | Description | | |
| 271 | - | |--------|-------------| | |
| 272 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 273 | - | | `--token` | Authentication token for remote | | |
| 274 | - | ||
| 275 | - | ### Examples | |
| 276 | - | ||
| 277 | - | ```bash | |
| 278 | - | # Remove database from a domain | |
| 279 | - | gordon attachments remove app.example.com postgres:18 | |
| 280 | - | ||
| 281 | - | # Remove from network group | |
| 282 | - | gordon attachments remove backend redis:7-alpine | |
| 283 | - | ||
| 284 | - | # Remote (override) | |
| 285 | - | gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 286 | - | ``` | |
| 287 | - | ||
| 288 | - | --- | |
| 289 | - | ||
| 290 | - | ## Workflow Examples | |
| 291 | - | ||
| 292 | - | ### Add Database and Cache | |
| 293 | - | ||
| 294 | - | ```bash | |
| 295 | - | # Add PostgreSQL database | |
| 296 | - | gordon attachments add app.example.com postgres:18 | |
| 297 | - | ||
| 298 | - | # Add Redis cache | |
| 299 | - | gordon attachments add app.example.com redis:7-alpine | |
| 300 | - | ||
| 301 | - | # Verify | |
| 302 | - | gordon attachments list app.example.com | |
| 303 | - | ``` | |
| 304 | - | ||
| 305 | - | ### Shared Services via Network Groups | |
| 306 | - | ||
| 307 | - | ```bash | |
| 308 | - | # First, ensure you have a network group defined in your config: | |
| 309 | - | # [network_groups] | |
| 310 | - | # "backend" = ["app.example.com", "api.example.com"] | |
| 311 | - | ||
| 312 | - | # Add shared cache to the group | |
| 313 | - | gordon attachments add backend redis:7-alpine | |
| 314 | - | ||
| 315 | - | # Both app.example.com and api.example.com can now access the shared Redis | |
| 316 | - | ``` | |
| 317 | - | ||
| 318 | - | ### First Deploy with Custom Attachment Image | |
| 319 | - | ||
| 320 | - | ```bash | |
| 321 | - | # Configure the route and attachment | |
| 322 | - | gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql | |
| 323 | - | ||
| 324 | - | # Push the custom attachment image first | |
| 325 | - | gordon attachments push pitlane-pgsql --build | |
| 326 | - | ||
| 327 | - | # Then push and deploy the route image | |
| 328 | - | gordon push myapp:latest --domain app.example.com --build --no-confirm | |
| 329 | - | ``` | |
| 330 | - | ||
| 331 | - | ### CI/CD Integration | |
| 332 | - | ||
| 333 | - | ```bash | |
| 334 | - | # In your CI/CD pipeline | |
| 335 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 336 | - | export GORDON_TOKEN=$GORDON_TOKEN | |
| 337 | - | ||
| 338 | - | # Add new attachment | |
| 339 | - | gordon attachments add app.example.com elasticsearch:8 | |
| 340 | - | ||
| 341 | - | # Trigger redeploy to pick up the new attachment | |
| 342 | - | gordon routes deploy app.example.com | |
| 343 | - | ``` | |
| 344 | - | ||
| 345 | - | ## Related | |
| 346 | - | ||
| 347 | - | - [CLI Overview](./index.md) | |
| 348 | - | - [Attachments Configuration](../config/attachments.md) | |
| 349 | - | - [Network Groups](../config/network-groups.md) | |
M docs/cli/auth.md +3 -3
| @@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 135 | 135 | ||
| 136 | 136 | Registry scopes: `push`, `pull`, `push,pull` | |
| 137 | 137 | ||
| 138 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` | |
| 138 | + | Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` | |
| 139 | 139 | ||
| 140 | 140 | Combine scopes with commas: | |
| 141 | 141 | ||
| @@ -271,8 +271,8 @@ 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 | | |
D docs/cli/autoroute.md +0 -205
| @@ -1,205 +0,0 @@ | |||
| 1 | - | # Auto-Route Commands | |
| 2 | - | ||
| 3 | - | Manage the auto-route domain allowlist on local or remote Gordon instances. | |
| 4 | - | ||
| 5 | - | When auto-route is enabled, Gordon handles image pushes in two ways: | |
| 6 | - | ||
| 7 | - | 1. **Image-name matching** — when a pushed image matches an existing route's configured image, Gordon auto-deploys that route. Since the route already exists, no allowlist check is needed. | |
| 8 | - | 2. **Label-based creation** — when a pushed image carries `gordon.domain` labels, Gordon creates new routes for those domains. The domain allowlist restricts which domains may be created this way, preventing untrusted images from registering arbitrary domains. | |
| 9 | - | ||
| 10 | - | The allowlist gates only the creation of new routes from labels. Routes created manually with `gordon routes add` or already present in configuration are not subject to the allowlist. | |
| 11 | - | ||
| 12 | - | Remote targeting uses client config or an active remote by default. | |
| 13 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 14 | - | ||
| 15 | - | ## gordon autoroute | |
| 16 | - | ||
| 17 | - | Manage auto-route settings. | |
| 18 | - | ||
| 19 | - | ### Subcommands | |
| 20 | - | ||
| 21 | - | | Subcommand | Description | | |
| 22 | - | |------------|-------------| | |
| 23 | - | | `allow` | Manage auto-route allowed domains | | |
| 24 | - | ||
| 25 | - | --- | |
| 26 | - | ||
| 27 | - | ## gordon autoroute allow | |
| 28 | - | ||
| 29 | - | Manage auto-route allowed domains. | |
| 30 | - | ||
| 31 | - | ### Subcommands | |
| 32 | - | ||
| 33 | - | | Subcommand | Description | | |
| 34 | - | |------------|-------------| | |
| 35 | - | | `add` | Add a domain pattern to the allowlist | | |
| 36 | - | | `list` | List allowed domains | | |
| 37 | - | | `remove` | Remove a domain pattern | | |
| 38 | - | ||
| 39 | - | --- | |
| 40 | - | ||
| 41 | - | ## gordon autoroute allow add | |
| 42 | - | ||
| 43 | - | Add a domain pattern to the auto-route allowlist. | |
| 44 | - | ||
| 45 | - | ```bash | |
| 46 | - | gordon autoroute allow add <pattern> | |
| 47 | - | gordon autoroute allow add example.com | |
| 48 | - | gordon autoroute allow add "*.staging.example.com" | |
| 49 | - | gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 50 | - | ``` | |
| 51 | - | ||
| 52 | - | ### Arguments | |
| 53 | - | ||
| 54 | - | | Argument | Description | | |
| 55 | - | |----------|-------------| | |
| 56 | - | | `<pattern>` | Domain pattern to allow for auto-route | | |
| 57 | - | ||
| 58 | - | ### Options | |
| 59 | - | ||
| 60 | - | | Option | Description | | |
| 61 | - | |--------|-------------| | |
| 62 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 63 | - | | `--token` | Authentication token for remote | | |
| 64 | - | ||
| 65 | - | ### Description | |
| 66 | - | ||
| 67 | - | Patterns must be lowercase and must not have trailing dots. | |
| 68 | - | A bare `*` matches all domains. Wildcards in `*.domain.tld` form match exactly one subdomain level, so `*.example.com` matches `app.example.com` but not `api.app.example.com`. | |
| 69 | - | ||
| 70 | - | ### Examples | |
| 71 | - | ||
| 72 | - | ```bash | |
| 73 | - | # Allow an exact domain | |
| 74 | - | gordon autoroute allow add example.com | |
| 75 | - | ||
| 76 | - | # Allow one subdomain level under staging.example.com | |
| 77 | - | gordon autoroute allow add "*.staging.example.com" | |
| 78 | - | ||
| 79 | - | # Remote (override) | |
| 80 | - | gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 81 | - | ``` | |
| 82 | - | ||
| 83 | - | ### Output | |
| 84 | - | ||
| 85 | - | ```text | |
| 86 | - | ✓ Allowed domain added | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | --- | |
| 90 | - | ||
| 91 | - | ## gordon autoroute allow list | |
| 92 | - | ||
| 93 | - | List auto-route allowed domains. | |
| 94 | - | ||
| 95 | - | ```bash | |
| 96 | - | gordon autoroute allow list | |
| 97 | - | gordon autoroute allow list --json | |
| 98 | - | gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN | |
| 99 | - | ``` | |
| 100 | - | ||
| 101 | - | ### Options | |
| 102 | - | ||
| 103 | - | | Option | Description | | |
| 104 | - | |--------|-------------| | |
| 105 | - | | `--json` | Output allowed domains as JSON | | |
| 106 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 107 | - | | `--token` | Authentication token for remote | | |
| 108 | - | ||
| 109 | - | ### Description | |
| 110 | - | ||
| 111 | - | Shows the current allowlist used to restrict which domains auto-route may claim. | |
| 112 | - | When no patterns are configured, the command prints a friendly empty-state message instead of a table. | |
| 113 | - | ||
| 114 | - | ### Examples | |
| 115 | - | ||
| 116 | - | ```bash | |
| 117 | - | # Local | |
| 118 | - | gordon autoroute allow list | |
| 119 | - | ||
| 120 | - | # JSON | |
| 121 | - | gordon autoroute allow list --json | |
| 122 | - | ||
| 123 | - | # Remote (override) | |
| 124 | - | gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN | |
| 125 | - | ``` | |
| 126 | - | ||
| 127 | - | ### Output | |
| 128 | - | ||
| 129 | - | ```text | |
| 130 | - | example.com | |
| 131 | - | *.staging.example.com | |
| 132 | - | ``` | |
| 133 | - | ||
| 134 | - | When the allowlist is empty: | |
| 135 | - | ||
| 136 | - | ```text | |
| 137 | - | No allowed domains configured | |
| 138 | - | ``` | |
| 139 | - | ||
| 140 | - | ### JSON Output | |
| 141 | - | ||
| 142 | - | ```bash | |
| 143 | - | gordon autoroute allow list --json | |
| 144 | - | ``` | |
| 145 | - | ||
| 146 | - | ```json | |
| 147 | - | { | |
| 148 | - | "domains": ["example.com", "*.staging.example.com"] | |
| 149 | - | } | |
| 150 | - | ``` | |
| 151 | - | ||
| 152 | - | --- | |
| 153 | - | ||
| 154 | - | ## gordon autoroute allow remove | |
| 155 | - | ||
| 156 | - | Remove a domain pattern from the auto-route allowlist. | |
| 157 | - | ||
| 158 | - | ```bash | |
| 159 | - | gordon autoroute allow remove <pattern> | |
| 160 | - | gordon autoroute allow remove example.com | |
| 161 | - | gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 162 | - | ``` | |
| 163 | - | ||
| 164 | - | ### Arguments | |
| 165 | - | ||
| 166 | - | | Argument | Description | | |
| 167 | - | |----------|-------------| | |
| 168 | - | | `<pattern>` | Domain pattern to remove from the allowlist | | |
| 169 | - | ||
| 170 | - | ### Options | |
| 171 | - | ||
| 172 | - | | Option | Description | | |
| 173 | - | |--------|-------------| | |
| 174 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 175 | - | | `--token` | Authentication token for remote | | |
| 176 | - | ||
| 177 | - | ### Description | |
| 178 | - | ||
| 179 | - | Removes an exact allowlist entry. | |
| 180 | - | Use the same pattern string you added, including the `*.` prefix for wildcard entries. | |
| 181 | - | ||
| 182 | - | ### Examples | |
| 183 | - | ||
| 184 | - | ```bash | |
| 185 | - | # Remove an exact domain | |
| 186 | - | gordon autoroute allow remove example.com | |
| 187 | - | ||
| 188 | - | # Remove a wildcard domain | |
| 189 | - | gordon autoroute allow remove "*.staging.example.com" | |
| 190 | - | ||
| 191 | - | # Remote (override) | |
| 192 | - | gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 193 | - | ``` | |
| 194 | - | ||
| 195 | - | ### Output | |
| 196 | - | ||
| 197 | - | ```text | |
| 198 | - | ✓ Allowed domain removed | |
| 199 | - | ``` | |
| 200 | - | ||
| 201 | - | ## Related | |
| 202 | - | ||
| 203 | - | - [CLI Overview](./index.md) | |
| 204 | - | - [Routes Command](./routes.md) | |
| 205 | - | - [Config Command](./config.md) | |
M docs/cli/backup.md +77 -27
| @@ -1,71 +1,121 @@ | |||
| 1 | 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 | + | [[service]] | |
| 15 | + | name = "api" | |
| 16 | + | image = "registry.example.com/shop/api:1.4.2" | |
| 17 | + | ||
| 18 | + | [[service.database]] | |
| 19 | + | name = "orders" | |
| 20 | + | type = "postgres" | |
| 21 | + | schedule = "daily" | |
| 22 | + | ||
| 23 | + | [[service.volume]] | |
| 24 | + | name = "data" | |
| 25 | + | path = "/var/lib/data" | |
| 26 | + | ||
| 27 | + | [service.backup] | |
| 28 | + | postgres = ["orders"] | |
| 29 | + | volume = ["data"] | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | A declared database or volume that the service's backup declaration does not | |
| 33 | + | reference is not a backup target. | |
| 34 | + | ||
| 35 | + | ## gordon backups | |
| 6 | 36 | ||
| 7 | 37 | Database backups are logical PostgreSQL backups made with `pg_dump`. | |
| 8 | 38 | ||
| 9 | 39 | ```bash | |
| 10 | - | gordon backups databases <subcommand> | |
| 40 | + | gordon backups <subcommand> | |
| 11 | 41 | ``` | |
| 12 | 42 | ||
| 13 | 43 | Subcommands: | |
| 14 | 44 | ||
| 15 | - | - `list [domain]` - List database backups | |
| 16 | - | - `run <domain> [--db <name>]` - Trigger an immediate database backup | |
| 17 | - | - `detect <domain>` - Detect supported databases for a domain | |
| 18 | - | - `status` - Show database backup status | |
| 19 | - | ||
| 20 | - | Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups. | |
| 45 | + | - `list [app]` - List stored backups, for every app or one app | |
| 46 | + | - `run <app> --service <service> --database <database>` - Run one declared | |
| 47 | + | database backup now | |
| 48 | + | - `status` - Show stored backups plus declared targets that have no completed | |
| 49 | + | backup yet | |
| 21 | 50 | ||
| 22 | - | ## gordon backups volumes | |
| 51 | + | ## gordon backups volume | |
| 23 | 52 | ||
| 24 | - | Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. | |
| 53 | + | Volume backups are best-effort filesystem archives of the app's declared | |
| 54 | + | volumes, uploaded to S3. | |
| 25 | 55 | ||
| 26 | 56 | ```bash | |
| 27 | - | gordon backups volumes <subcommand> | |
| 57 | + | gordon backups volume <subcommand> | |
| 28 | 58 | ``` | |
| 29 | 59 | ||
| 30 | 60 | Subcommands: | |
| 31 | 61 | ||
| 32 | - | - `list [domain]` - List completed volume backup archives | |
| 33 | - | - `run [domain] [--volume <name>]` - Trigger volume backups now | |
| 62 | + | - `list [app]` - List completed volume backup archives | |
| 63 | + | - `run <app> --service <service> --volume <volume>` - Run one declared volume | |
| 64 | + | backup now | |
| 34 | 65 | - `status` - Show completed archives plus current/recent in-memory job state | |
| 35 | 66 | ||
| 36 | - | Volume backups exclude bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes. Live archives are not application-consistent unless the application is quiesced or stopped. | |
| 67 | + | Volume archives are not application-consistent unless the application is | |
| 68 | + | quiesced or stopped. | |
| 69 | + | ||
| 70 | + | ## Selectors | |
| 71 | + | ||
| 72 | + | `--service` and the resource flag (`--database`, `--volume`) select the target: | |
| 73 | + | ||
| 74 | + | - both given: the target must exist, otherwise the command reports not found; | |
| 75 | + | - one omitted: it is only allowed when exactly one compatible target remains; | |
| 76 | + | - several candidates: the command reports the ambiguity and lists the safe | |
| 77 | + | `service/resource` names. | |
| 78 | + | ||
| 79 | + | Nothing is ever chosen by guessing. | |
| 37 | 80 | ||
| 38 | 81 | ## Examples | |
| 39 | 82 | ||
| 40 | 83 | ```bash | |
| 41 | 84 | # Database backups | |
| 42 | - | gordon backups databases list | |
| 43 | - | gordon backups databases run app.example.com --db postgres | |
| 44 | - | gordon backups databases detect app.example.com | |
| 45 | - | gordon backups databases status | |
| 85 | + | gordon backups list | |
| 86 | + | gordon backups list shop | |
| 87 | + | gordon backups run shop --service api --database orders | |
| 88 | + | gordon backups status | |
| 46 | 89 | ||
| 47 | 90 | # Volume backups | |
| 48 | - | gordon backups volumes list | |
| 49 | - | gordon backups volumes list app.example.com | |
| 50 | - | gordon backups volumes run | |
| 51 | - | gordon backups volumes run app.example.com --volume gordon-app-example-com-data | |
| 52 | - | gordon backups volumes status | |
| 91 | + | gordon backups volume list | |
| 92 | + | gordon backups volume list shop | |
| 93 | + | gordon backups volume run shop --service api --volume data | |
| 94 | + | gordon backups volume status | |
| 53 | 95 | ``` | |
| 54 | 96 | ||
| 97 | + | ## Scheduling | |
| 98 | + | ||
| 99 | + | The installation backup schedule runs every declared database whose own | |
| 100 | + | `schedule` matches the firing tier, and applies retention under the app name. | |
| 101 | + | Volume declarations carry no schedule of their own: the installation volume | |
| 102 | + | backup interval runs the declared volume targets. | |
| 103 | + | ||
| 55 | 104 | ## JSON Output | |
| 56 | 105 | ||
| 57 | - | Database and volume list commands support `--json`. | |
| 106 | + | Every list, run, and status command supports `--json`. | |
| 58 | 107 | ||
| 59 | 108 | ```bash | |
| 60 | - | gordon backups volumes list --json | |
| 109 | + | gordon backups volume list --json | |
| 61 | 110 | ``` | |
| 62 | 111 | ||
| 63 | 112 | ## Required Permissions | |
| 64 | 113 | ||
| 65 | - | - Read operations (`list`, `status`, `detect`) require `admin:status:read`. | |
| 114 | + | - Read operations (`list`, `status`) require `admin:status:read`. | |
| 66 | 115 | - `run` requires `admin:config:write`. | |
| 67 | 116 | ||
| 68 | 117 | ## Related | |
| 69 | 118 | ||
| 70 | 119 | - [CLI Commands](./index.md) | |
| 71 | 120 | - [Backups Configuration](../config/backups.md) | |
| 121 | + | - [Apps Configuration](../config/apps.md) | |
D docs/cli/bootstrap.md +0 -80
| @@ -1,80 +0,0 @@ | |||
| 1 | - | # Bootstrap Command | |
| 2 | - | ||
| 3 | - | Create or update route configuration, attachments, and secrets in one command. | |
| 4 | - | ||
| 5 | - | ## gordon bootstrap | |
| 6 | - | ||
| 7 | - | ### Synopsis | |
| 8 | - | ||
| 9 | - | ```bash | |
| 10 | - | gordon bootstrap <domain> <image> [options] | |
| 11 | - | ``` | |
| 12 | - | ||
| 13 | - | ### Arguments | |
| 14 | - | ||
| 15 | - | | Argument | Description | | |
| 16 | - | |----------|-------------| | |
| 17 | - | | `<domain>` | The route domain to create or update | | |
| 18 | - | | `<image>` | The image to assign to the route | | |
| 19 | - | ||
| 20 | - | ### Options | |
| 21 | - | ||
| 22 | - | | Option | Description | | |
| 23 | - | |--------|-------------| | |
| 24 | - | | `--attachment` | Add an attachment to the route (repeatable) | | |
| 25 | - | | `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) | | |
| 26 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 27 | - | | `--token` | Authentication token for remote | | |
| 28 | - | ||
| 29 | - | ### Description | |
| 30 | - | ||
| 31 | - | `gordon bootstrap` is the recommended first-step setup workflow. | |
| 32 | - | ||
| 33 | - | - Creates the route when it does not exist. | |
| 34 | - | - Updates the route when it already exists. | |
| 35 | - | - Can attach services and set environment variables as part of the same command. | |
| 36 | - | - Does not push or deploy the image. | |
| 37 | - | ||
| 38 | - | Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image. | |
| 39 | - | ||
| 40 | - | ### Examples | |
| 41 | - | ||
| 42 | - | ```bash | |
| 43 | - | # First-time route setup | |
| 44 | - | gordon bootstrap app.example.com myapp:latest | |
| 45 | - | ||
| 46 | - | # Then push and deploy the image | |
| 47 | - | gordon push myapp:latest --domain app.example.com --build --no-confirm | |
| 48 | - | ||
| 49 | - | # First-time route setup with a database attachment and environment variable | |
| 50 | - | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 51 | - | ||
| 52 | - | # Multiple attachments and environment variables | |
| 53 | - | gordon bootstrap app.example.com myapp:latest \ | |
| 54 | - | --attachment postgres:18 \ | |
| 55 | - | --attachment redis:7-alpine \ | |
| 56 | - | --env APP_ENV=production \ | |
| 57 | - | --env LOG_LEVEL=info | |
| 58 | - | ||
| 59 | - | # Remote target | |
| 60 | - | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |
| 61 | - | ||
| 62 | - | # Push custom attachment image first | |
| 63 | - | gordon attachments push pitlane-pgsql --build | |
| 64 | - | ||
| 65 | - | # Then push and deploy the route image | |
| 66 | - | gordon push myapp --build --no-confirm | |
| 67 | - | ``` | |
| 68 | - | ||
| 69 | - | ### Notes | |
| 70 | - | ||
| 71 | - | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | |
| 72 | - | - Run `gordon push` after bootstrap to upload and deploy the image. | |
| 73 | - | - If attachments use custom images that are not available from a public registry, push those attachment images with `gordon attachments push` before deploying the route image. | |
| 74 | - | ||
| 75 | - | ## Related | |
| 76 | - | ||
| 77 | - | - [CLI Overview](./index.md) | |
| 78 | - | - [Attachments Commands](./attachments.md) | |
| 79 | - | - [Routes Commands](./routes.md) | |
| 80 | - | - [Push Command](./push.md) | |
M docs/cli/config.md +10 -16
| @@ -17,8 +17,9 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 17 | 17 | ||
| 18 | 18 | ## gordon config show | |
| 19 | 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. | |
| 20 | + | Display the Gordon installation configuration including server settings, | |
| 21 | + | network isolation, volumes, and external route domains. App routes live | |
| 22 | + | under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default. | |
| 22 | 23 | ||
| 23 | 24 | ```bash | |
| 24 | 25 | gordon config show | |
| @@ -41,17 +42,15 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||
| 41 | 42 | "registry_port": 5000, | |
| 42 | 43 | "registry_domain": "reg.example.com" | |
| 43 | 44 | }, | |
| 44 | - | "auto_route": { | |
| 45 | - | "enabled": true, | |
| 46 | - | "allowed_domains": ["example.com", "*.staging.example.com"] | |
| 47 | - | }, | |
| 48 | 45 | "network_isolation": { | |
| 49 | 46 | "enabled": true, | |
| 50 | - | "prefix": "gordon_" | |
| 47 | + | "prefix": "gordon" | |
| 48 | + | }, | |
| 49 | + | "volumes": { | |
| 50 | + | "auto_create": true, | |
| 51 | + | "prefix": "gordon", | |
| 52 | + | "preserve": true | |
| 51 | 53 | }, | |
| 52 | - | "routes": [ | |
| 53 | - | {"domain": "app.example.com", "image": "myapp:latest"} | |
| 54 | - | ], | |
| 55 | 54 | "external_routes": [ | |
| 56 | 55 | {"domain": "reg.example.com"} | |
| 57 | 56 | ] | |
| @@ -60,13 +59,8 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||
| 60 | 59 | ||
| 61 | 60 | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |
| 62 | 61 | ||
| 63 | - | ### Auto-Route Allowed Domains | |
| 64 | - | ||
| 65 | - | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | |
| 66 | - | ||
| 67 | 62 | ## Related | |
| 68 | 63 | ||
| 69 | 64 | - [CLI Overview](./index.md) | |
| 70 | - | - [Auto-Route Commands](./autoroute.md) | |
| 71 | 65 | - [Status Command](./status.md) | |
| 72 | - | - [Routes Command](./routes.md) | |
| 66 | + | - [Apps Commands](./apps.md) | |
M docs/cli/images.md +21 -3
| @@ -59,11 +59,13 @@ gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] | |||
| 59 | 59 | ||
| 60 | 60 | By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations. | |
| 61 | 61 | ||
| 62 | + | A prune only ever deletes resources it can positively prove safe. Every other candidate is reported as `protected` or `unknown` and left in place; the command still succeeds. See [Prune safety](#prune-safety). | |
| 63 | + | ||
| 62 | 64 | Flags: | |
| 63 | 65 | ||
| 64 | 66 | | Flag | Default | Description | | |
| 65 | 67 | |------|---------|-------------| | |
| 66 | - | | `--dry-run` | `false` | Show prune behavior without applying changes | | |
| 68 | + | | `--dry-run` | `false` | Run the full inventory and planning, then report; nothing is deleted | | |
| 67 | 69 | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | | |
| 68 | 70 | | `--dangling` | `false` | Restrict scope to dangling runtime images only | | |
| 69 | 71 | | `--registry` | `false` | Restrict scope to registry tag retention only | | |
| @@ -80,7 +82,22 @@ Flags: | |||
| 80 | 82 | ||
| 81 | 83 | - `latest` is always preserved when present. | |
| 82 | 84 | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. | |
| 83 | - | - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags. | |
| 85 | + | - A tag named by durable app state survives beyond the retention window. | |
| 86 | + | - `--keep-releases=0` skips registry tag and blob cleanup entirely; dangling runtime prune still runs. | |
| 87 | + | ||
| 88 | + | ## Prune safety | |
| 89 | + | ||
| 90 | + | Gordon prunes by ownership, not by name or age. Each candidate gets one verdict: | |
| 91 | + | ||
| 92 | + | | Verdict | Meaning | | |
| 93 | + | |---------|---------| | |
| 94 | + | | `eligible` | Every fact needed to prove the resource safe was read completely, and no durable record claims it. It is deleted. | | |
| 95 | + | | `protected` | A durable fact claims it: a desired or active app service (including stopped apps), a recovery inhibition, a staged or committed apply intent, an unfinished operation journal entry, container use, a pending upload, an ownership record, or the OCI closure of any of those. It survives. | | |
| 96 | + | | `unknown` | A fact needed to prove it safe was missing, unreadable, or unsupported. It survives. | | |
| 97 | + | ||
| 98 | + | Runtime images used by any container, running or stopped, always survive. Registry blobs reachable from a retained or protected manifest survive, including blobs shared with another tag. When a retained manifest cannot be read, the blobs whose safety depended on it become `unknown` rather than eligible. | |
| 99 | + | ||
| 100 | + | Both `--dry-run` and the executed prune return the same report: the verdict counts, the deleted identities, any deletion failures, every inventory gap, and the skipped candidates with their reason codes. A prune that deletes nothing is a success. | |
| 84 | 101 | ||
| 85 | 102 | ## gordon images tags | |
| 86 | 103 | ||
| @@ -128,7 +145,8 @@ gordon images prune --dangling | |||
| 128 | 145 | # Prune registry tags only, keeping latest + 5 previous | |
| 129 | 146 | gordon images prune --registry --keep-releases 5 | |
| 130 | 147 | ||
| 131 | - | # Preview cleanup without applying | |
| 148 | + | # Inspect what prune would remove without applying | |
| 149 | + | # (also lists every skipped candidate with its reason) | |
| 132 | 150 | gordon images prune --dry-run | |
| 133 | 151 | ||
| 134 | 152 | # Skip confirmation prompt | |
M docs/cli/index.md +43 -109
| @@ -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,23 @@ 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) | | |
| 27 | + | | `gordon apps` | Manage applications (apply, deploy, lifecycle) | [apps](./apps.md) | | |
| 28 | + | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | |
| 31 | 29 | | `gordon config show` | Show server configuration | [config](./config.md) | | |
| 32 | - | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | |
| 33 | 30 | | `gordon images` | List and prune images | [images](./images.md) | | |
| 34 | - | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 31 | + | | `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) | | |
| 35 | 32 | | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) | | |
| 36 | - | | `gordon preview` | Create or manage preview environments | [preview](../config/preview.md) | | |
| 37 | - | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | |
| 38 | - | | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) | | |
| 39 | - | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 40 | - | | `gordon restart` | Restart a running container | [restart](./restart.md) | | |
| 41 | - | | `gordon pin` | Pin a route to a specific image tag | [pin](./pin.md) | | |
| 42 | - | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| 43 | - | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 33 | + | | `gordon push` | Tag and push an image (never deploys) | [push](./push.md) | | |
| 34 | + | | `gordon reload` | Reload installation configuration | [serve](./serve.md#gordon-reload) | | |
| 35 | + | | `gordon secrets` | Manage installation secrets | [secrets](./secrets.md) | | |
| 44 | 36 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 37 | + | | `gordon tls` | Inspect TLS status | [tls](./tls.md) | | |
| 45 | 38 | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |
| 46 | - | | `gordon volumes` | Manage volumes | - | | |
| 39 | + | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | |
| 47 | 40 | ||
| 48 | 41 | ## Client Commands | |
| 49 | 42 | ||
| @@ -60,36 +53,30 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 60 | 53 | gordon serve | |
| 61 | 54 | gordon serve --config /path/to/config.toml | |
| 62 | 55 | ||
| 63 | - | # Reload configuration | |
| 56 | + | # Reload installation configuration (never activates app state) | |
| 64 | 57 | gordon reload | |
| 65 | 58 | ||
| 66 | - | # Deploy a specific route | |
| 67 | - | gordon deploy myapp.example.com | |
| 68 | - | ||
| 69 | - | # Restart a running container | |
| 70 | - | gordon restart myapp.example.com | |
| 71 | - | ||
| 72 | - | # First-time route setup | |
| 73 | - | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 74 | - | ||
| 75 | - | # Then push and deploy | |
| 76 | - | gordon push myapp:latest --domain app.example.com --build --no-confirm | |
| 77 | - | ||
| 78 | - | # Push an image and deploy | |
| 79 | - | gordon push myapp --build | |
| 80 | - | ||
| 81 | - | # Push and deploy without confirmation | |
| 82 | - | gordon push myapp --no-confirm | |
| 83 | - | ||
| 84 | - | # Pin to a specific tag | |
| 85 | - | gordon pin myapp.example.com | |
| 59 | + | # Applications (daemon-owned; fail without a reachable daemon) | |
| 60 | + | gordon apps apply --file blog.toml | |
| 61 | + | gordon apps apply --file blog.toml --deploy | |
| 62 | + | gordon apps list | |
| 63 | + | gordon apps show blog | |
| 64 | + | gordon apps diff blog | |
| 65 | + | gordon apps deploy blog | |
| 66 | + | gordon apps restart blog | |
| 67 | + | gordon apps stop blog | |
| 68 | + | gordon apps start blog | |
| 69 | + | gordon apps remove blog | |
| 70 | + | ||
| 71 | + | # Push an image (OCI transfer only; deploy separately) | |
| 72 | + | gordon push myapp --build --remote prod | |
| 86 | 73 | ||
| 87 | 74 | # View logs | |
| 88 | - | gordon logs # Gordon process logs | |
| 89 | - | gordon logs -f # Follow process logs | |
| 90 | - | gordon logs -n 100 # Last 100 lines | |
| 91 | - | gordon logs myapp.example.com # Container logs for myapp.example.com | |
| 92 | - | gordon logs myapp.example.com -f # Follow container logs | |
| 75 | + | gordon logs # Gordon process logs | |
| 76 | + | gordon logs -f # Follow process logs | |
| 77 | + | gordon logs -n 100 # Last 100 process-log lines | |
| 78 | + | gordon apps logs blog --service web # App service logs | |
| 79 | + | gordon apps logs blog --service web --follow # Follow app service logs | |
| 93 | 80 | ||
| 94 | 81 | # Check version | |
| 95 | 82 | gordon version | |
| @@ -100,14 +87,14 @@ gordon traffic status --remote prod --json | |||
| 100 | 87 | ||
| 101 | 88 | # Backups | |
| 102 | 89 | gordon backups list | |
| 103 | - | gordon backups run app.example.com | |
| 104 | - | gordon backups detect app.example.com | |
| 90 | + | gordon backups run shop --service api --database orders | |
| 91 | + | gordon backups volume run shop --service api --volume data | |
| 105 | 92 | gordon backups status | |
| 106 | 93 | ||
| 107 | 94 | # Images | |
| 108 | 95 | gordon images list | |
| 109 | - | gordon images prune --runtime-only | |
| 110 | - | gordon images prune --keep 3 | |
| 96 | + | gordon images prune --dry-run | |
| 97 | + | gordon images prune --keep-releases 3 | |
| 111 | 98 | ||
| 112 | 99 | # Authentication | |
| 113 | 100 | gordon auth login --remote https://gordon.example.com --token $TOKEN | |
| @@ -119,21 +106,9 @@ gordon auth token list | |||
| 119 | 106 | gordon auth token revoke <token-id> | |
| 120 | 107 | gordon auth internal | |
| 121 | 108 | ||
| 122 | - | # Routes | |
| 123 | - | gordon routes list | |
| 124 | - | gordon routes status | |
| 125 | - | gordon routes add myapp.example.com myapp:latest | |
| 126 | - | gordon routes remove myapp.example.com | |
| 127 | - | gordon routes deploy myapp.example.com | |
| 128 | - | ||
| 129 | - | # Attachments | |
| 130 | - | gordon attachments list | |
| 131 | - | gordon attachments add app.example.com postgres:18 | |
| 132 | - | gordon attachments remove app.example.com postgres:18 | |
| 133 | - | ||
| 134 | 109 | # Secrets | |
| 135 | 110 | gordon secrets list myapp.example.com | |
| 136 | - | gordon secrets set myapp.example.com DATABASE_URL "postgres://..." | |
| 111 | + | gordon secrets set myapp.example.com --from-file ./app.env | |
| 137 | 112 | gordon secrets remove myapp.example.com DATABASE_URL | |
| 138 | 113 | ||
| 139 | 114 | # Remotes | |
| @@ -141,21 +116,9 @@ gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |||
| 141 | 116 | gordon remotes list | |
| 142 | 117 | gordon remotes use prod | |
| 143 | 118 | ||
| 144 | - | # Preview environments | |
| 145 | - | gordon preview create app.example.com --branch feature-x | |
| 146 | - | gordon preview list | |
| 147 | - | gordon preview extend app.example.com --branch feature-x | |
| 148 | - | gordon preview delete app.example.com --branch feature-x | |
| 149 | - | ||
| 150 | 119 | # Volumes | |
| 151 | 120 | gordon volumes list | |
| 152 | 121 | gordon volumes prune | |
| 153 | - | ||
| 154 | - | # Auto-route allowlist | |
| 155 | - | gordon autoroute allow list | |
| 156 | - | gordon autoroute allow add example.com | |
| 157 | - | gordon autoroute allow add "*.staging.example.com" | |
| 158 | - | gordon autoroute allow remove example.com | |
| 159 | 122 | ``` | |
| 160 | 123 | ||
| 161 | 124 | ## Global Options | |
| @@ -173,55 +136,26 @@ The CLI can target remote Gordon instances using client config, an active remote | |||
| 173 | 136 | or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides | |
| 174 | 137 | when you want to bypass your saved configuration. | |
| 175 | 138 | ||
| 176 | - | When no explicit remote is selected and no active remote is configured, Gordon can now | |
| 177 | - | **auto-infer a saved remote** for commands that already have a concrete target. It probes your | |
| 178 | - | saved remotes and uses the remote automatically when exactly one matches. If multiple remotes | |
| 179 | - | match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe | |
| 180 | - | fails, Gordon also stops rather than guessing. | |
| 181 | - | ||
| 182 | - | Auto-inference currently applies to target-based commands such as: | |
| 183 | - | - `gordon push` | |
| 184 | - | - `gordon attachments push` | |
| 185 | - | - `gordon deploy <domain>` | |
| 186 | - | - `gordon restart <domain>` | |
| 187 | - | - `gordon pin <domain>` / `gordon pin list <domain>` | |
| 188 | - | - `gordon routes show <domain>` / `gordon routes remove <domain>` | |
| 189 | - | - `gordon secrets list|set|remove <domain>` | |
| 190 | - | - `gordon attachments list <target>` / `gordon attachments add|remove <target> ...` | |
| 191 | - | - `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>` | |
| 192 | - | - `gordon images tags <repository>` | |
| 193 | - | - `gordon logs <domain>` | |
| 194 | - | ||
| 195 | - | `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote` | |
| 196 | - | nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either | |
| 197 | - | one to force a single target. | |
| 139 | + | When no explicit remote is selected and no active remote is configured, Gordon can | |
| 140 | + | **auto-infer a saved remote** for `gordon push`. It probes your saved remotes and uses the | |
| 141 | + | remote automatically when exactly one matches. If multiple remotes match, Gordon stops with | |
| 142 | + | an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also | |
| 143 | + | stops rather than guessing. | |
| 198 | 144 | ||
| 199 | 145 | ```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 | 146 | # Auto-inferred single match from saved remotes | |
| 209 | 147 | gordon push myapp --build | |
| 210 | - | gordon deploy app.example.com | |
| 211 | 148 | ``` | |
| 212 | 149 | ||
| 213 | 150 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | |
| 214 | - | If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured. | |
| 215 | 151 | ||
| 216 | 152 | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |
| 217 | 153 | You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml` | |
| 218 | 154 | or in a specific entry in `~/.config/gordon/remotes.toml`. | |
| 219 | - | For Tailscale setups, you can also avoid `--insecure` by using the machine `*.ts.net` name with Tailscale-issued TLS certs in Gordon server config. | |
| 220 | - | Your public app domains can still use wildcard DNS and normal reverse-proxy routing. | |
| 221 | 155 | ||
| 222 | 156 | ```bash | |
| 223 | 157 | # Using flags (use the gordon_domain from remote Gordon config) | |
| 224 | - | gordon routes list --remote https://gordon.example.com --token $TOKEN | |
| 158 | + | gordon status --remote https://gordon.example.com --token $TOKEN | |
| 225 | 159 | ||
| 226 | 160 | # Against self-signed/private CA endpoint | |
| 227 | 161 | gordon --remote https://gordon.example.com --token $TOKEN --insecure status | |
| @@ -230,7 +164,7 @@ gordon --remote https://gordon.example.com --token $TOKEN --insecure status | |||
| 230 | 164 | export GORDON_REMOTE=https://gordon.example.com | |
| 231 | 165 | export GORDON_TOKEN=$TOKEN | |
| 232 | 166 | export GORDON_INSECURE=true | |
| 233 | - | gordon routes list | |
| 167 | + | gordon status | |
| 234 | 168 | ``` | |
| 235 | 169 | ||
| 236 | 170 | ## Exit Codes | |
M docs/cli/networks.md +0 -2
| @@ -47,5 +47,3 @@ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 47 | 47 | ## Related | |
| 48 | 48 | ||
| 49 | 49 | - [CLI Overview](./index.md) | |
| 50 | - | - [Routes Command](./routes.md) | |
| 51 | - | - [Attachments Command](./attachments.md) | |
D docs/cli/pin.md +0 -72
| @@ -1,72 +0,0 @@ | |||
| 1 | - | # Pin Command | |
| 2 | - | ||
| 3 | - | Pin a route to a specific image tag. | |
| 4 | - | ||
| 5 | - | ## gordon pin | |
| 6 | - | ||
| 7 | - | ### Synopsis | |
| 8 | - | ||
| 9 | - | ```bash | |
| 10 | - | gordon pin <domain> [options] | |
| 11 | - | gordon pin list <domain> | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | ### Arguments | |
| 15 | - | ||
| 16 | - | | Argument | Description | | |
| 17 | - | |----------|-------------| | |
| 18 | - | | `<domain>` | The route domain to pin | | |
| 19 | - | ||
| 20 | - | ### Options | |
| 21 | - | ||
| 22 | - | | Option | Description | | |
| 23 | - | |--------|-------------| | |
| 24 | - | | `--tag` | Target tag (skips interactive selection) | | |
| 25 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 26 | - | | `--token` | Authentication token for remote | | |
| 27 | - | | `--json` | Output as JSON (for `pin list`) | | |
| 28 | - | ||
| 29 | - | ### Description | |
| 30 | - | ||
| 31 | - | `gordon pin` deploys a selected image tag for the given route. Tags are | |
| 32 | - | listed from the Gordon registry, with semver tags sorted in descending | |
| 33 | - | order first, followed by non-semver tags. | |
| 34 | - | ||
| 35 | - | Use cases: | |
| 36 | - | - **Rollback** — revert to a previous stable version | |
| 37 | - | - **Forward** — advance to a newer version | |
| 38 | - | - **Lock** — pin to a specific version instead of following `latest` | |
| 39 | - | - **Test** — switch to an experimental tag | |
| 40 | - | ||
| 41 | - | `gordon pin list <domain>` lists available tags without deploying and | |
| 42 | - | marks the current tag. | |
| 43 | - | ||
| 44 | - | If the selected tag matches the current running tag, no action is taken. | |
| 45 | - | ||
| 46 | - | ### Examples | |
| 47 | - | ||
| 48 | - | ```bash | |
| 49 | - | # Interactive selection | |
| 50 | - | gordon pin myapp.example.com | |
| 51 | - | ||
| 52 | - | # Pin to a specific tag | |
| 53 | - | gordon pin myapp.example.com --tag v1.2.0 | |
| 54 | - | ||
| 55 | - | # List available tags for a domain | |
| 56 | - | gordon pin list myapp.example.com | |
| 57 | - | ||
| 58 | - | # List available tags as JSON | |
| 59 | - | gordon pin list myapp.example.com --json | |
| 60 | - | ``` | |
| 61 | - | ||
| 62 | - | ### Notes | |
| 63 | - | ||
| 64 | - | - Local by default; remote mode optional. See [CLI Overview](./index.md) for targeting options. | |
| 65 | - | - Tags are read from the Gordon registry configured on the server. | |
| 66 | - | ||
| 67 | - | ## Related | |
| 68 | - | ||
| 69 | - | - [CLI Overview](./index.md) | |
| 70 | - | - [Push Command](./push.md) | |
| 71 | - | - [Routes Command](./routes.md) | |
| 72 | - | - [Deployment Rollback Strategies](../deployment/rollback.md) | |
D docs/cli/preview.md +0 -61
| @@ -1,61 +0,0 @@ | |||
| 1 | - | # gordon preview | |
| 2 | - | ||
| 3 | - | Create and manage ephemeral preview environments. Each preview gets its own subdomain, container, and optionally cloned volumes from the base route. | |
| 4 | - | ||
| 5 | - | ## Commands | |
| 6 | - | ||
| 7 | - | ### `gordon preview create` | |
| 8 | - | ||
| 9 | - | Build, push, and deploy a preview environment. | |
| 10 | - | ||
| 11 | - | ```bash | |
| 12 | - | gordon preview create myapp | |
| 13 | - | gordon preview create myapp --ttl 72h | |
| 14 | - | gordon preview create myapp --no-build --no-data | |
| 15 | - | ``` | |
| 16 | - | ||
| 17 | - | | Flag | Description | | |
| 18 | - | |------|-------------| | |
| 19 | - | | `--ttl` | Override TTL duration (e.g., `72h`) | | |
| 20 | - | | `--no-build` | Skip image build (use existing image) | | |
| 21 | - | | `--no-data` | Skip volume cloning from base route | | |
| 22 | - | | `--platform` | Target platform for build (default `linux/amd64`) | | |
| 23 | - | ||
| 24 | - | ### `gordon preview list` | |
| 25 | - | ||
| 26 | - | List active preview environments with status and remaining TTL. | |
| 27 | - | ||
| 28 | - | ```bash | |
| 29 | - | gordon preview list | |
| 30 | - | gordon preview list --json | |
| 31 | - | ``` | |
| 32 | - | ||
| 33 | - | | Flag | Description | | |
| 34 | - | |------|-------------| | |
| 35 | - | | `--json` | Output as JSON | | |
| 36 | - | ||
| 37 | - | ### `gordon preview delete` | |
| 38 | - | ||
| 39 | - | Tear down a preview environment and clean up its resources. | |
| 40 | - | ||
| 41 | - | ```bash | |
| 42 | - | gordon preview delete myapp-feature-x | |
| 43 | - | ``` | |
| 44 | - | ||
| 45 | - | ### `gordon preview extend` | |
| 46 | - | ||
| 47 | - | Extend the TTL of an active preview. | |
| 48 | - | ||
| 49 | - | ```bash | |
| 50 | - | gordon preview extend myapp-feature-x | |
| 51 | - | gordon preview extend myapp-feature-x --ttl 48h | |
| 52 | - | ``` | |
| 53 | - | ||
| 54 | - | | Flag | Description | | |
| 55 | - | |------|-------------| | |
| 56 | - | | `--ttl` | Additional TTL to add (default `24h`) | | |
| 57 | - | ||
| 58 | - | ## Related | |
| 59 | - | ||
| 60 | - | - [Preview Configuration](../config/preview.md) | |
| 61 | - | - [Deploy](./serve.md#gordon-deploy) | |
M docs/cli/push.md +19 -51
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | # Push Command | |
| 2 | 2 | ||
| 3 | - | Tag, push, and optionally deploy an image to the Gordon registry. | |
| 3 | + | Tag and push an image to the Gordon registry. Push transfers OCI content | |
| 4 | + | only: it never deploys. Deploy separately with `gordon apps deploy` after | |
| 5 | + | applying the manifest that references the pushed tag. | |
| 4 | 6 | ||
| 5 | 7 | ## gordon push | |
| 6 | 8 | ||
| @@ -25,35 +27,22 @@ gordon push [image] [options] | |||
| 25 | 27 | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 26 | 28 | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 27 | 29 | | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) | | |
| 28 | - | | `--no-confirm` | Skip deploy confirmation prompt | | |
| 29 | - | | `--no-deploy` | Push only; skip deployment prompt | | |
| 30 | - | | `--domain` | Explicit deploy target override | | |
| 31 | 30 | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 32 | 31 | | `--token` | Authentication token for remote | | |
| 33 | 32 | ||
| 34 | 33 | ### Description | |
| 35 | 34 | ||
| 36 | - | `gordon push` tags the selected image for the Gordon registry, pushes it, and | |
| 37 | - | optionally deploys matching routes. | |
| 35 | + | `gordon push` tags the selected image for the Gordon registry and pushes it. | |
| 38 | 36 | ||
| 39 | 37 | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |
| 40 | - | infer the correct saved remote by probing your saved remotes for a matching route. | |
| 38 | + | infer the correct saved remote by probing your saved remotes for the image. | |
| 41 | 39 | It auto-selects only when exactly one remote matches. If multiple remotes match, | |
| 42 | 40 | or a saved remote cannot be probed safely, the command stops and asks you to use | |
| 43 | 41 | `--remote` explicitly. | |
| 44 | 42 | ||
| 45 | - | Image resolution order: | |
| 46 | - | 1. `--domain` is the explicit deploy target override for legacy workflows | |
| 47 | - | 2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup | |
| 48 | - | 3. Dotted positional refs probe image routes first, then fall back to legacy domain lookup | |
| 49 | - | 4. No-arg mode auto-detects from the Dockerfile label or current directory | |
| 43 | + | Domain-style push targets are retired: push an image name, then | |
| 44 | + | `gordon apps apply` the manifest that references the pushed tag. | |
| 50 | 45 | ||
| 51 | - | The pushed version still comes from `--tag`, CI tag refs, or `git describe --tags --dirty`. | |
| 52 | - | ||
| 53 | - | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | |
| 54 | - | ||
| 55 | - | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | |
| 56 | - | - The registry and repository are derived from the route image on the server. | |
| 57 | 46 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| 58 | 47 | then falls back to `git describe --tags --dirty` (for example | |
| 59 | 48 | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | |
| @@ -69,20 +58,11 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus | |||
| 69 | 58 | ||
| 70 | 59 | When used with `--remote`, gordon push authenticates in two ways: | |
| 71 | 60 | ||
| 72 | - | - **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token | |
| 61 | + | - **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token | |
| 73 | 62 | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |
| 74 | 63 | ||
| 75 | 64 | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |
| 76 | 65 | ||
| 77 | - | ### Deploy Modes | |
| 78 | - | ||
| 79 | - | When the token has `admin:config:write` scope, the CLI manages deployment explicitly | |
| 80 | - | (DeployIntent → push → Deploy). This gives the CLI control over deploy timing. | |
| 81 | - | ||
| 82 | - | When the token lacks `admin:config:write`, the CLI pushes the image and the server | |
| 83 | - | auto-deploys when it receives it via its event listener. The CLI logs: | |
| 84 | - | `info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive` | |
| 85 | - | ||
| 86 | 66 | ### Version Auto-Detection | |
| 87 | 67 | ||
| 88 | 68 | Gordon reads version tags from CI environment variables (in priority order): | |
| @@ -99,40 +79,32 @@ Gordon reads version tags from CI environment variables (in priority order): | |||
| 99 | 79 | ### Examples | |
| 100 | 80 | ||
| 101 | 81 | ```bash | |
| 102 | - | # Build, push, and deploy (auto-detect image name) | |
| 103 | - | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 104 | - | ||
| 105 | - | # Push an image and deploy it | |
| 106 | - | gordon push myapp --remote https://gordon.example.com --no-confirm | |
| 82 | + | # Build, push (auto-detect image name) | |
| 83 | + | gordon push --build --remote https://gordon.example.com | |
| 107 | 84 | ||
| 108 | - | # Push and deploy to an explicit domain | |
| 109 | - | gordon push myapp:latest --domain app.example.com --remote https://gordon.example.com --no-confirm | |
| 85 | + | # Push an image | |
| 86 | + | gordon push myapp --remote https://gordon.example.com | |
| 110 | 87 | ||
| 111 | - | # Tagged refs still resolve routes by image name | |
| 112 | - | gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy | |
| 88 | + | # Push a fully qualified ref | |
| 89 | + | gordon push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com | |
| 113 | 90 | ||
| 114 | - | # Push existing local image, skip deploy | |
| 115 | - | gordon push myapp --tag v1.2.0 --no-deploy | |
| 91 | + | # Push existing local image with an explicit tag | |
| 92 | + | gordon push myapp --tag v1.2.0 --remote https://gordon.example.com | |
| 116 | 93 | ||
| 117 | 94 | # Build for ARM and pass build args | |
| 118 | - | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | |
| 95 | + | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com | |
| 119 | 96 | ||
| 120 | 97 | # Build from a custom Dockerfile path | |
| 121 | - | gordon push myapp --build -f docker/app/Dockerfile | |
| 122 | - | ||
| 123 | - | # Legacy compatibility: domain-looking positional target | |
| 124 | - | gordon push app.example.com --no-confirm | |
| 98 | + | gordon push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com | |
| 125 | 99 | ||
| 126 | 100 | # CI/CD usage (single env var, no docker login needed) | |
| 127 | 101 | export GORDON_TOKEN="your-token" | |
| 128 | - | gordon push myapp --build --remote https://gordon.example.com --no-confirm | |
| 102 | + | gordon push myapp --build --remote https://gordon.example.com | |
| 129 | 103 | ``` | |
| 130 | 104 | ||
| 131 | 105 | ### Notes | |
| 132 | 106 | ||
| 133 | 107 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 134 | - | - `gordon push` requires the target route to already exist so it can resolve the deploy target. | |
| 135 | - | Use `gordon bootstrap` for first deploys. | |
| 136 | 108 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 137 | 109 | install the `docker-buildx-plugin` package. | |
| 138 | 110 | - Gordon uses native registry uploads instead of shelling out to `docker push`. | |
| @@ -144,8 +116,4 @@ gordon push myapp --build --remote https://gordon.example.com --no-confirm | |||
| 144 | 116 | ## Related | |
| 145 | 117 | ||
| 146 | 118 | - [CLI Overview](./index.md) | |
| 147 | - | - [Deployment Overview](../deployment/index.md) | |
| 148 | - | - [GitHub Actions](../deployment/github-actions.md) | |
| 149 | - | - [Attachments Commands](./attachments.md) | |
| 150 | - | - [Routes Command](./routes.md) | |
| 151 | 119 | - [Authentication](../config/auth.md) | |
M docs/cli/remotes.md +11 -22
| @@ -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 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 | 171 | gordon secrets list app.com # Uses prod remote automatically | |
| 173 | - | gordon routes list # Aggregates local + saved remotes | |
| 174 | - | gordon routes status # Aggregates local + saved remotes | |
| 172 | + | gordon status # Uses prod remote automatically | |
| 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 status | |
| 281 | 274 | ``` | |
| 282 | 275 | ||
| 283 | 276 | --- | |
| @@ -295,11 +288,11 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN | |||
| 295 | 288 | # Work with prod | |
| 296 | 289 | gordon remotes use prod | |
| 297 | 290 | gordon secrets list myapp.example.com | |
| 298 | - | gordon routes list --remote prod | |
| 291 | + | gordon status --remote prod | |
| 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 status | |
| 303 | 296 | ``` | |
| 304 | 297 | ||
| 305 | 298 | ### CI/CD Pipeline | |
| @@ -311,21 +304,17 @@ 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 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 status --remote https://gordon.example.com --token $PROD_TOKEN | |
| 317 | + | GORDON_REMOTE=staging gordon status | |
| 329 | 318 | ||
| 330 | 319 | # Or switch between active remotes for other commands | |
| 331 | 320 | gordon remotes use prod && gordon secrets list myapp.example.com | |
| @@ -354,7 +343,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal | |||
| 354 | 343 | ||
| 355 | 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) | |
M docs/cli/secrets.md +32 -84
| @@ -1,32 +1,31 @@ | |||
| 1 | 1 | # Secrets Commands | |
| 2 | 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). | |
| 3 | + | Manage installation secrets on local or remote Gordon instances. | |
| 9 | 4 | ||
| 10 | 5 | Storage depends on the secrets backend: | |
| 11 | 6 | - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>` | |
| 12 | 7 | - `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values | |
| 13 | 8 | - `unsafe`: secrets are stored in plain-text domain `.env` files | |
| 14 | 9 | ||
| 10 | + | App secret VALUES stay in pass under | |
| 11 | + | `gordon/apps/<uuid>/<service>/<name>` and are managed with | |
| 12 | + | `gordon apps secrets`. The commands below manage installation secrets only. | |
| 13 | + | ||
| 15 | 14 | ## gordon secrets | |
| 16 | 15 | ||
| 17 | 16 | ### Subcommands | |
| 18 | 17 | ||
| 19 | 18 | | Subcommand | Description | | |
| 20 | 19 | |------------|-------------| | |
| 21 | - | | `list` | List all secrets for a domain | | |
| 22 | - | | `set` | Set a secret value | | |
| 20 | + | | `list` | List all secret keys for a domain | | |
| 21 | + | | `set` | Set secrets for a domain from a file | | |
| 23 | 22 | | `remove` | Remove a secret | | |
| 24 | 23 | ||
| 25 | 24 | --- | |
| 26 | 25 | ||
| 27 | 26 | ## gordon secrets list | |
| 28 | 27 | ||
| 29 | - | List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets. | |
| 28 | + | List all secret key names for a specific domain. Only key names are shown, never values. | |
| 30 | 29 | ||
| 31 | 30 | ```bash | |
| 32 | 31 | gordon secrets list <domain> | |
| @@ -62,13 +61,8 @@ gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --tok | |||
| 62 | 61 | Secrets for app.mydomain.com | |
| 63 | 62 | ||
| 64 | 63 | Key Value | |
| 65 | - | DATABASE_URL **** | |
| 66 | - | API_KEY **** | |
| 67 | - | ├─ [postgres] | |
| 68 | - | │ ├─ POSTGRES_USER **** | |
| 69 | - | │ └─ POSTGRES_PASSWORD **** | |
| 70 | - | └─ [redis] | |
| 71 | - | └─ REDIS_PASSWORD **** | |
| 64 | + | DATABASE_URL (hidden) | |
| 65 | + | API_KEY (hidden) | |
| 72 | 66 | ``` | |
| 73 | 67 | ||
| 74 | 68 | ### JSON Output | |
| @@ -80,19 +74,7 @@ gordon secrets list app.mydomain.com --json | |||
| 80 | 74 | ```json | |
| 81 | 75 | { | |
| 82 | 76 | "domain": "app.mydomain.com", | |
| 83 | - | "secrets": { | |
| 84 | - | "DATABASE_URL": "****", | |
| 85 | - | "API_KEY": "****" | |
| 86 | - | }, | |
| 87 | - | "attachments": { | |
| 88 | - | "postgres": { | |
| 89 | - | "POSTGRES_USER": "****", | |
| 90 | - | "POSTGRES_PASSWORD": "****" | |
| 91 | - | }, | |
| 92 | - | "redis": { | |
| 93 | - | "REDIS_PASSWORD": "****" | |
| 94 | - | } | |
| 95 | - | } | |
| 77 | + | "keys": ["API_KEY", "DATABASE_URL"] | |
| 96 | 78 | } | |
| 97 | 79 | ``` | |
| 98 | 80 | ||
| @@ -100,25 +82,24 @@ gordon secrets list app.mydomain.com --json | |||
| 100 | 82 | ||
| 101 | 83 | ## gordon secrets set | |
| 102 | 84 | ||
| 103 | - | Set a secret value for a domain. | |
| 85 | + | Set secrets for a domain from a mode 0600 file containing one `KEY=value` pair per line. | |
| 104 | 86 | ||
| 105 | 87 | ```bash | |
| 106 | - | gordon secrets set <domain> <KEY=value>... | |
| 107 | - | gordon secrets set myapp.example.com DATABASE_URL="postgres://..." | |
| 88 | + | gordon secrets set <domain> --from-file <path> | |
| 108 | 89 | ``` | |
| 109 | 90 | ||
| 110 | 91 | ### Arguments | |
| 111 | 92 | ||
| 112 | 93 | | Argument | Description | | |
| 113 | 94 | |----------|-------------| | |
| 114 | - | | `<domain>` | The domain name to set the secret for | | |
| 115 | - | | `<KEY=value>...` | One or more secret key/value pairs | | |
| 95 | + | | `<domain>` | The domain name to set secrets for | | |
| 116 | 96 | ||
| 117 | 97 | ### Options | |
| 118 | 98 | ||
| 119 | 99 | | Option | Description | | |
| 120 | 100 | |--------|-------------| | |
| 121 | - | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |
| 101 | + | | `--from-file` | Read KEY=value lines from a mode 0600 file (required) | | |
| 102 | + | | `--json` | Output as JSON | | |
| 122 | 103 | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 123 | 104 | | `--token` | Authentication token for remote | | |
| 124 | 105 | ||
| @@ -126,18 +107,10 @@ gordon secrets set myapp.example.com DATABASE_URL="postgres://..." | |||
| 126 | 107 | ||
| 127 | 108 | ```bash | |
| 128 | 109 | # Local | |
| 129 | - | gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db" | |
| 130 | - | gordon secrets set myapp.example.com API_KEY="your-api-key" | |
| 110 | + | gordon secrets set myapp.example.com --from-file ./app.env | |
| 131 | 111 | ||
| 132 | 112 | # Remote (override) | |
| 133 | - | gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN | |
| 134 | - | ||
| 135 | - | # Set attachment secrets | |
| 136 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 137 | - | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret | |
| 138 | - | ||
| 139 | - | # Multiple secrets at once | |
| 140 | - | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 113 | + | gordon secrets set myapp.example.com --from-file ./app.env --remote https://gordon.mydomain.com --token $TOKEN | |
| 141 | 114 | ``` | |
| 142 | 115 | ||
| 143 | 116 | --- | |
| @@ -161,8 +134,7 @@ gordon secrets remove <domain> <key> | |||
| 161 | 134 | ||
| 162 | 135 | | Option | Description | | |
| 163 | 136 | |--------|-------------| | |
| 164 | - | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |
| 165 | - | | `--force` | Remove without confirmation | | |
| 137 | + | | `--force`, `-f` | Remove without confirmation | | |
| 166 | 138 | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 167 | 139 | | `--token` | Authentication token for remote | | |
| 168 | 140 | ||
| @@ -174,9 +146,6 @@ gordon secrets remove myapp.example.com DATABASE_URL | |||
| 174 | 146 | ||
| 175 | 147 | # Remote (override) | |
| 176 | 148 | gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | |
| 177 | - | ||
| 178 | - | # Remove attachment secret | |
| 179 | - | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |
| 180 | 149 | ``` | |
| 181 | 150 | ||
| 182 | 151 | --- | |
| @@ -186,15 +155,14 @@ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |||
| 186 | 155 | ### Setting Up Application Secrets | |
| 187 | 156 | ||
| 188 | 157 | ```bash | |
| 189 | - | # Database connection | |
| 190 | - | gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/mydb" | |
| 191 | - | ||
| 192 | - | # API keys | |
| 193 | - | gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..." | |
| 194 | - | gordon secrets set myapp.example.com SENDGRID_KEY="SG..." | |
| 158 | + | # Write secrets to a protected file | |
| 159 | + | cat > app.env <<'EOF' | |
| 160 | + | DATABASE_URL=postgres://user:pass@postgres:5432/mydb | |
| 161 | + | API_KEY=your-api-key | |
| 162 | + | EOF | |
| 163 | + | chmod 600 app.env | |
| 195 | 164 | ||
| 196 | - | # JWT secret | |
| 197 | - | gordon secrets set myapp.example.com JWT_SECRET="your-jwt-secret-here" | |
| 165 | + | gordon secrets set myapp.example.com --from-file ./app.env | |
| 198 | 166 | ||
| 199 | 167 | # Verify | |
| 200 | 168 | gordon secrets list myapp.example.com | |
| @@ -207,12 +175,8 @@ gordon secrets list myapp.example.com | |||
| 207 | 175 | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 208 | 176 | export GORDON_TOKEN=$GORDON_TOKEN | |
| 209 | 177 | ||
| 210 | - | # Update secrets before deploy | |
| 211 | - | gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" | |
| 212 | - | gordon secrets set myapp.example.com API_KEY="$API_KEY" | |
| 213 | - | ||
| 214 | - | # Deploy | |
| 215 | - | gordon deploy myapp.example.com | |
| 178 | + | # Update secrets | |
| 179 | + | gordon secrets set myapp.example.com --from-file ./app.env | |
| 216 | 180 | ``` | |
| 217 | 181 | ||
| 218 | 182 | ### Rotating Secrets | |
| @@ -220,28 +184,11 @@ gordon deploy myapp.example.com | |||
| 220 | 184 | ```bash | |
| 221 | 185 | # Generate new secret | |
| 222 | 186 | NEW_JWT_SECRET=$(openssl rand -base64 32) | |
| 187 | + | printf 'JWT_SECRET=%s\n' "$NEW_JWT_SECRET" > rotate.env | |
| 188 | + | chmod 600 rotate.env | |
| 223 | 189 | ||
| 224 | 190 | # Update the secret | |
| 225 | - | gordon secrets set myapp.example.com JWT_SECRET="$NEW_JWT_SECRET" | |
| 226 | - | ||
| 227 | - | # Redeploy to pick up new secret | |
| 228 | - | gordon deploy myapp.example.com | |
| 229 | - | ``` | |
| 230 | - | ||
| 231 | - | ### Attachment Secrets | |
| 232 | - | ||
| 233 | - | ```bash | |
| 234 | - | # Configure database credentials | |
| 235 | - | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 236 | - | ||
| 237 | - | # Configure cache credentials | |
| 238 | - | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret | |
| 239 | - | ||
| 240 | - | # Verify | |
| 241 | - | gordon secrets list app.mydomain.com | |
| 242 | - | ||
| 243 | - | # Redeploy to pick up new secrets | |
| 244 | - | gordon deploy app.mydomain.com | |
| 191 | + | gordon secrets set myapp.example.com --from-file ./rotate.env | |
| 245 | 192 | ``` | |
| 246 | 193 | ||
| 247 | 194 | ## Related | |
| @@ -249,3 +196,4 @@ gordon deploy app.mydomain.com | |||
| 249 | 196 | - [CLI Overview](./index.md) | |
| 250 | 197 | - [Secrets Configuration](../config/secrets.md) | |
| 251 | 198 | - [Environment Variables](../config/env.md) | |
| 199 | + | - [Apps Commands](./apps.md) | |
M docs/cli/serve.md +26 -97
| @@ -23,8 +23,8 @@ gordon serve [options] | |||
| 23 | 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 | ||
| @@ -135,7 +130,7 @@ Startup recovery is intentionally narrower than a manual deploy. It only starts | |||
| 135 | 130 | ||
| 136 | 131 | ## gordon reload | |
| 137 | 132 | ||
| 138 | - | Reload configuration and sync containers to match. | |
| 133 | + | Reload installation configuration. | |
| 139 | 134 | ||
| 140 | 135 | ### Synopsis | |
| 141 | 136 | ||
| @@ -147,10 +142,10 @@ gordon reload | |||
| 147 | 142 | ||
| 148 | 143 | Sends `SIGUSR1` to the running Gordon process, triggering: | |
| 149 | 144 | ||
| 150 | - | - Configuration file reload | |
| 151 | - | - Route synchronization | |
| 152 | - | - Deployment of containers for routes missing containers | |
| 153 | - | - Attachment deployment | |
| 145 | + | - Installation settings reload (live keys apply, restart-required keys are reported) | |
| 146 | + | - Traffic/proxy state refresh from ACTIVE app projection | |
| 147 | + | ||
| 148 | + | Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation. | |
| 154 | 149 | ||
| 155 | 150 | ### Example | |
| 156 | 151 | ||
| @@ -162,80 +157,18 @@ gordon reload | |||
| 162 | 157 | ||
| 163 | 158 | --- | |
| 164 | 159 | ||
| 165 | - | ## gordon deploy | |
| 166 | - | ||
| 167 | - | Manually deploy or redeploy a specific route. | |
| 168 | - | ||
| 169 | - | ### Synopsis | |
| 170 | - | ||
| 171 | - | ```bash | |
| 172 | - | gordon deploy <domain> [options] | |
| 173 | - | ``` | |
| 174 | - | ||
| 175 | - | ### Arguments | |
| 176 | - | ||
| 177 | - | | Argument | Description | | |
| 178 | - | |----------|-------------| | |
| 179 | - | | `<domain>` | The domain name of the route to deploy (required) | | |
| 180 | - | ||
| 181 | - | ### Options | |
| 182 | - | ||
| 183 | - | | Option | Description | | |
| 184 | - | |--------|-------------| | |
| 185 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 186 | - | | `--token` | Authentication token for remote | | |
| 187 | - | ||
| 188 | - | Remote targeting uses client config or an active remote by default. | |
| 189 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 190 | - | ||
| 191 | - | ### Description | |
| 192 | - | ||
| 193 | - | **Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route. | |
| 194 | - | ||
| 195 | - | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request. | |
| 196 | - | ||
| 197 | - | Both local and remote manual deploys use Gordon's explicit deploy path: | |
| 198 | - | ||
| 199 | - | - Fresh image content is pulled for the route | |
| 200 | - | - The specified route is redeployed | |
| 201 | - | - Configured readiness checks and drain behavior apply when needed | |
| 202 | - | ||
| 203 | - | ### Examples | |
| 204 | - | ||
| 205 | - | ```bash | |
| 206 | - | # Local deployment | |
| 207 | - | gordon deploy myapp.example.com | |
| 208 | - | gordon deploy api.example.com | |
| 209 | - | ||
| 210 | - | # Remote deployment (override) | |
| 211 | - | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 212 | - | ``` | |
| 213 | - | ||
| 214 | - | ### Use Cases | |
| 215 | - | ||
| 216 | - | - Recover from a failed deployment | |
| 217 | - | - Force redeploy without pushing a new image | |
| 218 | - | - Manual deployment when automatic deploy didn't trigger | |
| 219 | - | - Trigger deployments on remote Gordon instances from CI/CD | |
| 220 | - | ||
| 221 | - | --- | |
| 222 | - | ||
| 223 | 160 | ## gordon logs | |
| 224 | 161 | ||
| 225 | - | Display Gordon process logs or container logs. | |
| 162 | + | Display Gordon daemon process logs. Application workload output is read with | |
| 163 | + | `gordon apps logs APP --service SVC`, which resolves the app's active container | |
| 164 | + | through the daemon. | |
| 226 | 165 | ||
| 227 | 166 | ### Synopsis | |
| 228 | 167 | ||
| 229 | 168 | ```bash | |
| 230 | - | gordon logs [domain] [options] | |
| 169 | + | gordon logs [options] | |
| 231 | 170 | ``` | |
| 232 | 171 | ||
| 233 | - | ### Arguments | |
| 234 | - | ||
| 235 | - | | Argument | Description | | |
| 236 | - | |----------|-------------| | |
| 237 | - | | `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. | | |
| 238 | - | ||
| 239 | 172 | ### Options | |
| 240 | 173 | ||
| 241 | 174 | | Option | Short | Default | Description | | |
| @@ -246,10 +179,8 @@ gordon logs [domain] [options] | |||
| 246 | 179 | | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 247 | 180 | | `--token` | | | Authentication token for remote | | |
| 248 | 181 | ||
| 249 | - | Remote targeting uses client config or an active remote by default. | |
| 250 | - | When you provide a concrete domain and no remote is selected, Gordon can also | |
| 251 | - | auto-infer a saved remote when exactly one match is found. | |
| 252 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 182 | + | Remote targeting uses client config or an active remote by default. Use | |
| 183 | + | `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 253 | 184 | ||
| 254 | 185 | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |
| 255 | 186 | ||
| @@ -262,14 +193,12 @@ gordon logs -f # Follow logs | |||
| 262 | 193 | gordon logs -n 100 # Last 100 lines | |
| 263 | 194 | gordon logs -f -n 200 # Follow, starting from last 200 lines | |
| 264 | 195 | ||
| 265 | - | # Container logs | |
| 266 | - | gordon logs myapp.example.com # Last 50 lines from container | |
| 267 | - | gordon logs myapp.example.com -f # Follow container logs | |
| 268 | - | gordon logs myapp.example.com -n 100 # Last 100 lines from container | |
| 196 | + | # App service logs | |
| 197 | + | gordon apps logs blog --service web | |
| 198 | + | gordon apps logs blog --service web --follow --remote prod | |
| 269 | 199 | ||
| 270 | - | # Remote mode (override) | |
| 200 | + | # Remote process logs (override) | |
| 271 | 201 | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |
| 272 | - | gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 273 | 202 | ``` | |
| 274 | 203 | ||
| 275 | 204 | ### Log Locations | |
| @@ -284,9 +213,9 @@ tail -f ~/.gordon/logs/gordon.log | |||
| 284 | 213 | # With systemd | |
| 285 | 214 | journalctl --user -u gordon -f | |
| 286 | 215 | ||
| 287 | - | # Container logs via docker (local alternative) | |
| 288 | - | docker logs --tail 50 myapp.example.com | |
| 289 | - | docker logs -f myapp.example.com | |
| 216 | + | # App service logs through Gordon | |
| 217 | + | gordon apps logs blog --service web --tail 50 | |
| 218 | + | gordon apps logs blog --service web --follow | |
| 290 | 219 | ``` | |
| 291 | 220 | ||
| 292 | 221 | --- | |
M docs/cli/status.md +20 -23
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | # Status Command | |
| 2 | 2 | ||
| 3 | - | Show Gordon server status and container health. | |
| 3 | + | Show Gordon server status and the app fleet summary. | |
| 4 | 4 | ||
| 5 | 5 | ## gordon status | |
| 6 | 6 | ||
| 7 | - | Display server configuration and container status for all routes. | |
| 7 | + | Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`. | |
| 8 | 8 | ||
| 9 | 9 | ```bash | |
| 10 | 10 | gordon status | |
| @@ -24,14 +24,13 @@ Gordon Status | |||
| 24 | 24 | Gordon Domain: gordon.example.com | |
| 25 | 25 | Registry Port: 5000 | |
| 26 | 26 | Server Port: 8088 | |
| 27 | - | Routes: 3 | |
| 28 | - | Auto-Route: true | |
| 27 | + | Apps: 3 | |
| 29 | 28 | Network Isolation: false | |
| 30 | 29 | ||
| 31 | 30 | Container Status: | |
| 32 | - | app.example.com: running | |
| 33 | - | api.example.com: running | |
| 34 | - | worker.example.com: stopped | |
| 31 | + | blog: active | |
| 32 | + | shop: deploying | |
| 33 | + | old-site: stopped | |
| 35 | 34 | ``` | |
| 36 | 35 | ||
| 37 | 36 | ### Information Displayed | |
| @@ -40,22 +39,19 @@ Container Status: | |||
| 40 | 39 | |-------|-------------| | |
| 41 | 40 | | Gordon Domain | Public Gordon domain from configuration | | |
| 42 | 41 | | Registry Port | Docker registry port | | |
| 43 | - | | Server Port | Gordon HTTP proxy port | | |
| 44 | - | | Routes | Total configured routes | | |
| 45 | - | | Auto-Route | Whether auto-routing is enabled | | |
| 46 | - | | Network Isolation | Whether network isolation is enabled | | |
| 47 | - | | Container Status | Status of each route's container | | |
| 42 | + | | Server Port | Gordon admin port | | |
| 43 | + | | Routes | Total apps in desired/active state | | |
| 44 | + | | Network Isolation | Whether installation network policy is enabled | | |
| 45 | + | | Container Status | Fleet status per app (see states below) | | |
| 48 | 46 | ||
| 49 | - | ### Container States | |
| 47 | + | ### App States | |
| 50 | 48 | ||
| 51 | 49 | | State | Description | | |
| 52 | 50 | |-------|-------------| | |
| 53 | - | | running | Container is running and healthy | | |
| 54 | - | | restarting | Container is in runtime restart backoff or restart cycle | | |
| 55 | - | | stopped | Container was stopped | | |
| 56 | - | | exited | Container exited (check logs for errors) | | |
| 57 | - | | paused | Container is paused | | |
| 58 | - | | unknown | Unable to determine container state | | |
| 51 | + | | active | App deployed and converged on desired state | | |
| 52 | + | | deploying | Desired state diverges from effective state | | |
| 53 | + | | pending | App applied but never deployed | | |
| 54 | + | | stopped | Durable stopped intent (stays stopped across reboot) | | |
| 59 | 55 | ||
| 60 | 56 | ## Flags | |
| 61 | 57 | ||
| @@ -90,11 +86,11 @@ export GORDON_TOKEN=your-token | |||
| 90 | 86 | gordon status | |
| 91 | 87 | ``` | |
| 92 | 88 | ||
| 93 | - | ### Quick Health Check | |
| 89 | + | ### Quick Fleet Check | |
| 94 | 90 | ||
| 95 | 91 | ```bash | |
| 96 | - | # Check if all containers are running | |
| 97 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)" | |
| 92 | + | # Check for non-converged apps | |
| 93 | + | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)" | |
| 98 | 94 | ``` | |
| 99 | 95 | ||
| 100 | 96 | ## Required Permissions (Remote Only) | |
| @@ -109,5 +105,6 @@ gordon auth token generate --subject admin --scopes admin:status:read | |||
| 109 | 105 | ## Related | |
| 110 | 106 | ||
| 111 | 107 | - [Serve Command](./serve.md) | |
| 112 | - | - [Routes Command](./routes.md) | |
| 108 | + | - [Apps Commands](./apps.md) | |
| 109 | + | - [CLI Overview](./index.md) | |
| 113 | 110 | - [Remote CLI Management](/wiki/guides/remote-cli.md) | |
M docs/cli/tls.md +0 -1
| @@ -111,4 +111,3 @@ The `token_source` field indicates where the ACME token was sourced from | |||
| 111 | 111 | ||
| 112 | 112 | - [CLI Overview](./index.md) | |
| 113 | 113 | - [Status Command](./status.md) | |
| 114 | - | - [Routes Command](./routes.md) | |
M docs/cli/volumes.md +14 -2
| @@ -19,7 +19,7 @@ gordon volumes list --json | |||
| 19 | 19 | ||
| 20 | 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 +99 -186
| @@ -9,276 +9,189 @@ 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 | |
| 63 | - | ||
| 64 | - | Gordon separates configuration removal from destructive data cleanup: | |
| 65 | - | ||
| 66 | - | - **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`. | |
| 67 | - | - **active** — an entity has runtime state, such as a running Gordon-managed container. | |
| 68 | - | - **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data. | |
| 69 | - | - **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis. | |
| 70 | - | - **purged** — state explicitly deleted by a destructive cleanup command. | |
| 71 | - | - **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later. | |
| 72 | - | ||
| 73 | - | Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion. | |
| 74 | - | ||
| 75 | - | Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time. | |
| 76 | - | ||
| 77 | - | Current runtime cleanup capabilities are intentionally conservative: | |
| 45 | + | ```toml | |
| 46 | + | name = "blog" | |
| 78 | 47 | ||
| 79 | - | - Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`. | |
| 80 | - | - Gordon can stop and remove containers. | |
| 81 | - | - Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category. | |
| 82 | - | - Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added. | |
| 48 | + | [env] | |
| 49 | + | APP_ENV = "production" # app-wide public env, injected into all services | |
| 83 | 50 | ||
| 84 | - | ## Routes | |
| 51 | + | [[service]] | |
| 52 | + | name = "web" | |
| 53 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 85 | 54 | ||
| 86 | - | Routes map domains to container images: | |
| 55 | + | [[service.http]] | |
| 56 | + | host = "blog.mydomain.com" | |
| 57 | + | port = 3000 | |
| 87 | 58 | ||
| 88 | - | ```toml | |
| 89 | - | [routes] | |
| 90 | - | "app.example.com" = { image = "myapp:latest" } | |
| 91 | - | "api.example.com" = { image = "myapi:v2.1.0" } | |
| 59 | + | [service.secrets] # ENV name -> secret name (values stay in pass) | |
| 60 | + | DATABASE_URL = "database-url" | |
| 92 | 61 | ``` | |
| 93 | 62 | ||
| 94 | - | Route domains must be plain hostnames such as `app.example.com`. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals. Legacy `http://...` keys are still read for backward compatibility and rewritten on the next save. | |
| 63 | + | - `apps apply --file FILE` validates and persists desired configuration only. | |
| 64 | + | - `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply. | |
| 65 | + | - `--dry-run` validates and previews without persistence or runtime effects. | |
| 66 | + | - Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content. | |
| 67 | + | - The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential. | |
| 68 | + | - Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command. | |
| 95 | 69 | ||
| 96 | - | When a request comes in for `app.example.com`, Gordon: | |
| 97 | - | ||
| 98 | - | 1. Looks up the route configuration | |
| 99 | - | 2. Finds the running container for `myapp:latest` | |
| 100 | - | 3. Proxies the request to that container | |
| 70 | + | ## Updates | |
| 101 | 71 | ||
| 102 | - | ### Route Domains | |
| 72 | + | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains with a bounded deadline, and retires that service's old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged. | |
| 103 | 73 | ||
| 104 | - | Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when `[tls.acme]` is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes. | |
| 74 | + | ``` | |
| 75 | + | Time ─────────────────────────────────────────────> | |
| 105 | 76 | ||
| 106 | - | ```toml | |
| 107 | - | [routes] | |
| 108 | - | "dev-app.example.com" = { image = "internal-app:latest", https = false } | |
| 77 | + | Old Container: [═══════════════════] | |
| 78 | + | ↓ retire | |
| 79 | + | New Container: [═════════════════════════> | |
| 80 | + | ↑ start ↑ traffic routed | |
| 109 | 81 | ``` | |
| 110 | 82 | ||
| 111 | - | ## Network Isolation | |
| 83 | + | TCP, UDP, mixed, and volume-owning services replace with interruption — no zero-downtime promise. An open UDP socket is not application readiness. Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | |
| 112 | 84 | ||
| 113 | - | Each app runs in its own isolated Docker network: | |
| 85 | + | ## Deletion and Cleanup Lifecycle | |
| 114 | 86 | ||
| 115 | - | ``` | |
| 116 | - | ┌────────────────────────────────────────────────┐ | |
| 117 | - | │ gordon-app-mydomain-com │ | |
| 118 | - | │ │ | |
| 119 | - | │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ | |
| 120 | - | │ │ App │───>│ Postgres │ │ Redis │ │ | |
| 121 | - | │ │ :3000 │ │ :5432 │ │ :6379 │ │ | |
| 122 | - | │ └──────────┘ └──────────┘ └──────────┘ │ | |
| 123 | - | │ │ | |
| 124 | - | └────────────────────────────────────────────────┘ | |
| 125 | - | ``` | |
| 87 | + | Gordon separates workload removal from destructive data cleanup: | |
| 126 | 88 | ||
| 127 | - | Benefits: | |
| 89 | + | - **desired** — the persisted manifest revision waiting to be activated. | |
| 90 | + | - **active** — the pinned, running definition (never inferred from containers). | |
| 91 | + | - **stopped** — durable stopped intent: workloads are down, data preserved, reboot keeps them stopped. | |
| 92 | + | - **retained** — volumes and secrets kept after app removal under the old internal UUID, visible but never implicitly adopted by a new app reusing the name. | |
| 128 | 93 | ||
| 129 | - | - Containers can't access each other's services | |
| 130 | - | - Services are only accessible by name within their network | |
| 131 | - | - No port conflicts between apps | |
| 94 | + | Safe removal is the default. `gordon apps remove` withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no `purge`: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes. | |
| 132 | 95 | ||
| 133 | - | ## Attachments | |
| 96 | + | ## App HTTP Hosts | |
| 134 | 97 | ||
| 135 | - | Attachments are service dependencies for your apps: | |
| 98 | + | An app's HTTP interfaces declare the hosts Gordon serves: | |
| 136 | 99 | ||
| 137 | 100 | ```toml | |
| 138 | - | [attachments] | |
| 139 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 101 | + | [[service.http]] | |
| 102 | + | host = "app.example.com" | |
| 103 | + | port = 3000 | |
| 140 | 104 | ``` | |
| 141 | 105 | ||
| 142 | - | Gordon deploys attachments to the same network as your app. Services are accessible by their image name: | |
| 106 | + | When a request comes in for `app.example.com`, Gordon: | |
| 107 | + | ||
| 108 | + | 1. Looks up the host in the ACTIVE projection (merged with installation external routes) | |
| 109 | + | 2. Finds the recorded loopback backend for that host (never a container IP — rootless-first) | |
| 110 | + | 3. Proxies the request to that backend | |
| 143 | 111 | ||
| 144 | - | ```javascript | |
| 145 | - | // In your app | |
| 146 | - | const db = await connect("postgresql://postgres:5432/mydb"); | |
| 147 | - | const cache = await connect("redis://redis:6379"); | |
| 148 | - | ``` | |
| 112 | + | Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`). | |
| 149 | 113 | ||
| 150 | - | ## Network Groups | |
| 114 | + | ## Networks | |
| 151 | 115 | ||
| 152 | - | Network groups allow multiple apps to share services: | |
| 116 | + | Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership: | |
| 153 | 117 | ||
| 154 | 118 | ```toml | |
| 155 | - | [network_groups] | |
| 156 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 157 | - | ||
| 158 | - | [attachments] | |
| 159 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 119 | + | [[network.shared]] | |
| 120 | + | network = "backend" | |
| 121 | + | services = ["web", "worker"] | |
| 160 | 122 | ``` | |
| 161 | 123 | ||
| 162 | - | Both `app.mydomain.com` and `api.mydomain.com` can access the shared services. | |
| 124 | + | Deploy adds AND removes memberships without disconnecting unrelated services. Short DNS names resolve privately; app-qualified aliases apply on shared networks. | |
| 163 | 125 | ||
| 164 | 126 | ## Volumes | |
| 165 | 127 | ||
| 166 | - | Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives: | |
| 128 | + | Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes: | |
| 167 | 129 | ||
| 168 | - | ```dockerfile | |
| 169 | - | FROM postgres:18 | |
| 170 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 130 | + | ```toml | |
| 131 | + | [[service.volume]] | |
| 132 | + | name = "web-data" | |
| 133 | + | path = "/data" | |
| 171 | 134 | ``` | |
| 172 | 135 | ||
| 173 | - | Volume behavior: | |
| 136 | + | Docker/Podman own the named volumes; Gordon records app UUID, service, and logical volume ownership. App manifests allow neither bind mounts nor service-shared volumes. Replacement and restart reuse volumes. Removing a service or app retains its volumes under the original app UUID, and a new app reusing the public name never adopts them. | |
| 174 | 137 | ||
| 175 | - | - **auto_create**: Volumes are created automatically (default: true) | |
| 176 | - | - **prefix**: Volume names are prefixed with `gordon-` (configurable) | |
| 177 | - | - **preserve**: Volumes persist across container updates (default: true) | |
| 138 | + | Use `gordon volumes prune --dry-run` to inspect Gordon's ownership-aware plan. Do not use `docker volume prune` or an equivalent runtime command for Gordon data: it bypasses Gordon's retention checks and can delete unmounted retained volumes. | |
| 178 | 139 | ||
| 179 | - | ## Environment Variables | |
| 140 | + | ## Environment and Secrets | |
| 180 | 141 | ||
| 181 | - | Gordon loads environment variables from files based on the domain: | |
| 142 | + | App-wide public env is declared in the manifest: | |
| 182 | 143 | ||
| 183 | - | ``` | |
| 184 | - | ~/.gordon/env/ | |
| 185 | - | ├── app_mydomain_com.env | |
| 186 | - | ├── api_mydomain_com.env | |
| 187 | - | └── admin_mydomain_com.env | |
| 144 | + | ```toml | |
| 145 | + | [env] | |
| 146 | + | APP_ENV = "production" | |
| 188 | 147 | ``` | |
| 189 | 148 | ||
| 190 | - | Domain dots become underscores: `app.mydomain.com` → `app_mydomain_com.env` | |
| 149 | + | Service-specific values use `secrets` even when non-confidential: | |
| 191 | 150 | ||
| 192 | - | Variables are merged in order: | |
| 193 | - | ||
| 194 | - | 1. Dockerfile `ENV` directives (lowest priority) | |
| 195 | - | 2. `.env` file values (highest priority) | |
| 196 | - | ||
| 197 | - | ### Secret Providers | |
| 198 | - | ||
| 199 | - | Environment files support secret provider syntax: | |
| 200 | - | ||
| 201 | - | ```bash | |
| 202 | - | # From Unix password manager (pass) | |
| 203 | - | DATABASE_PASSWORD=${pass:myapp/db-password} | |
| 204 | - | ||
| 205 | - | # From SOPS encrypted files | |
| 206 | - | API_SECRET=${sops:secrets.yaml:api.secret} | |
| 151 | + | ```toml | |
| 152 | + | [service.secrets] | |
| 153 | + | DATABASE_URL = "database-url" | |
| 207 | 154 | ``` | |
| 208 | 155 | ||
| 209 | - | ## Configuration Hot-Reload | |
| 210 | - | ||
| 211 | - | Gordon watches its config file and reloads automatically: | |
| 212 | - | ||
| 213 | - | 1. Edit `~/.config/gordon/gordon.toml` | |
| 214 | - | 2. Save the file | |
| 215 | - | 3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes | |
| 216 | - | 4. Containers and proxy config sync to match the new configuration | |
| 156 | + | Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by the stable internal UUID so a removed app's secrets are never adopted by a new app reusing the name. Secret updates affect the next deploy/restart, not running containers. Write values with `gordon apps secrets set`; only key names are ever echoed back, never values. | |
| 217 | 157 | ||
| 218 | - | Route file edits now reload automatically again. | |
| 158 | + | ## Installation Reload | |
| 219 | 159 | ||
| 220 | - | You can also trigger a reload: | |
| 160 | + | Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads. | |
| 221 | 161 | ||
| 222 | 162 | ```bash | |
| 223 | 163 | gordon reload | |
| 224 | 164 | ``` | |
| 225 | 165 | ||
| 226 | - | `gordon reload` sends `SIGUSR1` to the running Gordon process. | |
| 166 | + | `gordon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files. | |
| 227 | 167 | ||
| 228 | 168 | ## Event System | |
| 229 | 169 | ||
| 230 | - | Gordon uses an internal event system for coordination: | |
| 231 | - | ||
| 232 | - | | Event | Trigger | Action | | |
| 233 | - | |-------|---------|--------| | |
| 234 | - | | `image.pushed` | Image pushed to registry | Deploy container | | |
| 235 | - | | `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state | | |
| 236 | - | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |
| 237 | - | | `container.deployed` | Container started | Update proxy cache | | |
| 170 | + | Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests. | |
| 238 | 171 | ||
| 239 | 172 | ## Backups and Recovery | |
| 240 | 173 | ||
| 241 | - | Gordon can run logical PostgreSQL backups for attachment containers. | |
| 242 | - | ||
| 243 | - | Current design: | |
| 174 | + | Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[service.database]]`, `[service.backup]`); storage infrastructure (destinations, schedules, retention) stays global. | |
| 244 | 175 | ||
| 245 | - | 1. Detect PostgreSQL attachments attached to a route | |
| 246 | - | 2. Execute `pg_dump -Fc` through Gordon runtime operations | |
| 247 | - | 3. Store backup artifacts on local filesystem storage | |
| 248 | - | 4. Expose backup actions through admin API and CLI | |
| 176 | + | Stored backups are never deleted when declarations change — only schedules update on deploy. | |
| 249 | 177 | ||
| 250 | - | This is intentionally scoped for operational safety and predictable behavior. | |
| 251 | - | Future extensions can add physical backups, PITR, and remote object storage adapters. | |
| 252 | 178 | For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md). | |
| 253 | 179 | ||
| 254 | - | ## Container Labels | |
| 180 | + | ## Container Identity | |
| 255 | 181 | ||
| 256 | - | Gordon uses labels to track managed containers: | |
| 182 | + | Gordon stamps app ownership labels on every container and volume it creates: | |
| 257 | 183 | ||
| 258 | 184 | | Label | Purpose | | |
| 259 | 185 | |-------|---------| | |
| 260 | - | | `gordon.managed=true` | Identifies Gordon-managed containers | | |
| 261 | - | | `gordon.domain` | Domain this container serves | | |
| 262 | - | | `gordon.image` | Image name and tag | | |
| 263 | - | | `gordon.route` | Route this container handles | | |
| 264 | - | | `gordon.attachment=true` | Container is an attachment service | | |
| 265 | - | | `gordon.attached-to` | Which route this attachment serves | | |
| 266 | - | ||
| 267 | - | ## Proxy Port Selection | |
| 268 | - | ||
| 269 | - | When a container exposes multiple ports, Gordon needs to know which one serves HTTP: | |
| 270 | - | ||
| 271 | - | ```dockerfile | |
| 272 | - | FROM gitea/gitea:latest | |
| 273 | - | LABEL gordon.proxy.port=3000 # Route HTTP to port 3000 | |
| 274 | - | EXPOSE 22 # SSH | |
| 275 | - | EXPOSE 3000 # HTTP | |
| 276 | - | ``` | |
| 186 | + | | `gordon.managed=true` | Identifies Gordon-managed resources | | |
| 187 | + | | `gordon.app` | App public name | | |
| 188 | + | | `gordon.app.service` | Service name | | |
| 189 | + | | `gordon.app.revision` | Active revision that created it | | |
| 277 | 190 | ||
| 278 | - | Without the label, Gordon uses the first exposed port. | |
| 191 | + | Queries by logical identity use labels, never name parsing. Unknown resources (no labels, old labels) are preserved, never adopted or deleted. | |
| 279 | 192 | ||
| 280 | 193 | ## Related | |
| 281 | 194 | ||
| 282 | 195 | - [Configuration Reference](./config/index.md) | |
| 283 | - | - [Docker Labels Reference](./reference/docker-labels.md) | |
| 284 | - | - [Environment Variables](./config/env.md) | |
| 196 | + | - [Apps CLI](./cli/apps.md) | |
| 197 | + | - [Getting Started](./getting-started.md) | |
A docs/config/apps.md +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | # App Manifest | |
| 2 | + | ||
| 3 | + | Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`. | |
| 4 | + | ||
| 5 | + | The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential; values stay in pass. | |
| 6 | + | ||
| 7 | + | ## Minimal Example | |
| 8 | + | ||
| 9 | + | ```toml | |
| 10 | + | name = "blog" | |
| 11 | + | ||
| 12 | + | [[service]] | |
| 13 | + | name = "web" | |
| 14 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 15 | + | ||
| 16 | + | [[service.http]] | |
| 17 | + | host = "blog.mydomain.com" | |
| 18 | + | port = 3000 | |
| 19 | + | ``` | |
| 20 | + | ||
| 21 | + | ## Full Example | |
| 22 | + | ||
| 23 | + | ```toml | |
| 24 | + | name = "blog" | |
| 25 | + | ||
| 26 | + | [env] # optional, app-wide public env for all services | |
| 27 | + | APP_ENV = "production" | |
| 28 | + | ||
| 29 | + | [[service]] | |
| 30 | + | name = "web" | |
| 31 | + | image = "gordon.mydomain.com/blog:1.4.2" | |
| 32 | + | command = ["node", "server.js"] # optional override | |
| 33 | + | stop_grace = "10s" # optional, default 10s | |
| 34 | + | ||
| 35 | + | [service.readiness] # optional explicit readiness | |
| 36 | + | type = "http" | |
| 37 | + | path = "/healthz" | |
| 38 | + | port = 3000 | |
| 39 | + | timeout = "30s" | |
| 40 | + | ||
| 41 | + | [[service.http]] # 0..n HTTP interfaces | |
| 42 | + | host = "blog.mydomain.com" | |
| 43 | + | port = 3000 | |
| 44 | + | tls = "auto" # auto | always | never | |
| 45 | + | ||
| 46 | + | [service.secrets] # ENV name -> secret name (values in pass) | |
| 47 | + | DATABASE_URL = "database-url" | |
| 48 | + | ||
| 49 | + | [[service.volume]] # 0..n named volumes only | |
| 50 | + | name = "web-data" | |
| 51 | + | path = "/data" | |
| 52 | + | readonly = false | |
| 53 | + | ||
| 54 | + | [[service.database]] # explicit database declarations | |
| 55 | + | name = "main" | |
| 56 | + | type = "postgres" | |
| 57 | + | schedule = "daily" | |
| 58 | + | ||
| 59 | + | [service.backup] # backup targets by reference | |
| 60 | + | postgres = ["main"] | |
| 61 | + | volume = ["web-data"] | |
| 62 | + | ||
| 63 | + | [[network.shared]] # optional shared-network memberships | |
| 64 | + | network = "backend" | |
| 65 | + | services = ["web"] | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | ## Identity Rules | |
| 69 | + | ||
| 70 | + | - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness. | |
| 71 | + | - Service name: `[a-z0-9_.-]`, max 63, unique within the app. | |
| 72 | + | - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes. | |
| 73 | + | ||
| 74 | + | ## Services | |
| 75 | + | ||
| 76 | + | One or more explicitly named image-backed services per app. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none. | |
| 77 | + | ||
| 78 | + | ```toml | |
| 79 | + | [[service.tcp]] | |
| 80 | + | entrypoint = "game" # names installation entrypoints.<name> | |
| 81 | + | port = 25565 | |
| 82 | + | publish = "0.0.0.0:25565" | |
| 83 | + | ||
| 84 | + | [[service.udp]] | |
| 85 | + | entrypoint = "game" | |
| 86 | + | port = 28015 | |
| 87 | + | publish = "0.0.0.0:28015" | |
| 88 | + | ``` | |
| 89 | + | ||
| 90 | + | RCON is ordinary TCP: use TCP interfaces, no special RCON kind. | |
| 91 | + | ||
| 92 | + | `publish` must match the entrypoint listener it attaches to exactly: the same host, port, and transport. The traffic manager binds the entrypoint address, so a declaration that differs (for example a loopback bind on a wildcard entrypoint, or a different port) is rejected at apply time instead of being silently widened. `0.0.0.0` and an omitted host are the same wildcard. | |
| 93 | + | ||
| 94 | + | Image registry names and digest syntax are validated during manifest apply, resolution, deployment preflight, and immediately before every pull, including boot, restart, and recovery. Docker Hub (`docker.io` and canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry are allowed by default. Add other exact registry hostname+port entries with `images.allowed_registries`; this does not configure credentials, and external resolution and pulls use anonymous access. When `images.require_digest` is enabled, every image reference, including Gordon registry images, must use `@sha256:<64 hex chars>`. See [Images](./images.md). This is a hostname allowlist, not DNS/IP validation or runtime egress enforcement. | |
| 95 | + | ||
| 96 | + | ## Environment and Secrets | |
| 97 | + | ||
| 98 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[service.secrets]` key in the app. | |
| 99 | + | - `[service.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Secret updates affect the next deploy/restart, not running containers. | |
| 100 | + | - There is no `[service.env]` key — service-specific values must use secrets. | |
| 101 | + | ||
| 102 | + | ## Volumes and Databases | |
| 103 | + | ||
| 104 | + | - Named volumes only; no bind mounts, no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible. | |
| 105 | + | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | |
| 106 | + | - `[[service.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | |
| 107 | + | - `[service.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | |
| 108 | + | ||
| 109 | + | ## Readiness | |
| 110 | + | ||
| 111 | + | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. | |
| 112 | + | ||
| 113 | + | ## TLS | |
| 114 | + | ||
| 115 | + | - `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled. | |
| 116 | + | - `always` is enforced: a plaintext request to an `always` host is redirected whenever an HTTPS endpoint exists, and refused with `421 Misdirected Request` when none does. The backend is never reached over plaintext. | |
| 117 | + | - `never` stays plaintext and is never issued an app certificate. | |
| 118 | + | ||
| 119 | + | ## Networks | |
| 120 | + | ||
| 121 | + | Each app gets a private network automatically. `[[network.shared]]` adds services to named shared networks, created/reused only within verified Gordon ownership. Deploy adds AND removes memberships without disconnecting unrelated services. | |
| 122 | + | ||
| 123 | + | ## Strictness | |
| 124 | + | ||
| 125 | + | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | |
| 126 | + | ||
| 127 | + | ## Related | |
| 128 | + | ||
| 129 | + | - [Apps CLI](../cli/apps.md) | |
| 130 | + | - [Secrets](./secrets.md) | |
| 131 | + | - [Volumes](./volumes.md) | |
| 132 | + | - [External Routes](./external-routes.md) | |
D docs/config/attachments.md +0 -315
| @@ -1,315 +0,0 @@ | |||
| 1 | - | # Attachments Configuration | |
| 2 | - | ||
| 3 | - | Attach service dependencies (databases, caches, queues) to your applications. | |
| 4 | - | ||
| 5 | - | ## Requirements | |
| 6 | - | ||
| 7 | - | **Network isolation must be enabled** for attachments to work properly: | |
| 8 | - | ||
| 9 | - | ```toml | |
| 10 | - | [network_isolation] | |
| 11 | - | enabled = true | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | Without network isolation, containers run on Docker's default bridge network which **does not provide DNS resolution**. Your application won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |
| 15 | - | ||
| 16 | - | ## Configuration | |
| 17 | - | ||
| 18 | - | ```toml | |
| 19 | - | [attachments] | |
| 20 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 21 | - | "api.mydomain.com" = ["postgres:latest"] | |
| 22 | - | ``` | |
| 23 | - | ||
| 24 | - | ## Syntax | |
| 25 | - | ||
| 26 | - | ```toml | |
| 27 | - | [attachments] | |
| 28 | - | "<domain-or-group>" = ["<image>:<tag>", "<image>:<tag>"] | |
| 29 | - | ``` | |
| 30 | - | ||
| 31 | - | | Component | Description | | |
| 32 | - | |-----------|-------------| | |
| 33 | - | | `domain-or-group` | Route domain or network group name | | |
| 34 | - | | `image:tag` | Service images to deploy alongside the app | | |
| 35 | - | ||
| 36 | - | ## How Attachments Work | |
| 37 | - | ||
| 38 | - | When you define attachments: | |
| 39 | - | ||
| 40 | - | 1. Gordon deploys attachment containers to the same network as your app | |
| 41 | - | 2. Services are accessible by their image name (before the colon) | |
| 42 | - | 3. Attachments start before your main application | |
| 43 | - | 4. Attachments persist across app updates | |
| 44 | - | ||
| 45 | - | ``` | |
| 46 | - | ┌───────────────────────────────────────────────────┐ | |
| 47 | - | │ Network: gordon-app-mydomain-com │ | |
| 48 | - | │ │ | |
| 49 | - | │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ | |
| 50 | - | │ │ App │───>│ postgres │ │ redis │ │ | |
| 51 | - | │ │ :3000 │ │ :5432 │ │ :6379 │ │ | |
| 52 | - | │ └──────────┘ └──────────┘ └──────────┘ │ | |
| 53 | - | │ │ | |
| 54 | - | └───────────────────────────────────────────────────┘ | |
| 55 | - | ``` | |
| 56 | - | ||
| 57 | - | ## Service Discovery | |
| 58 | - | ||
| 59 | - | Attachments are accessible by their image name within the network: | |
| 60 | - | ||
| 61 | - | ```toml | |
| 62 | - | [attachments] | |
| 63 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 64 | - | ``` | |
| 65 | - | ||
| 66 | - | In your application: | |
| 67 | - | ```javascript | |
| 68 | - | // Connect using simple hostnames | |
| 69 | - | const db = await pg.connect("postgresql://postgres:5432/mydb"); | |
| 70 | - | const cache = await redis.connect("redis://redis:6379"); | |
| 71 | - | ``` | |
| 72 | - | ||
| 73 | - | ## Persistent Storage | |
| 74 | - | ||
| 75 | - | Use Dockerfile VOLUME directives for persistent data: | |
| 76 | - | ||
| 77 | - | ```dockerfile | |
| 78 | - | # postgres.Dockerfile | |
| 79 | - | FROM postgres:18 | |
| 80 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | - | ENV POSTGRES_DB=myapp | |
| 82 | - | ENV POSTGRES_USER=app | |
| 83 | - | # Password is injected via attachment secrets — do NOT hardcode here | |
| 84 | - | ``` | |
| 85 | - | ||
| 86 | - | Configure credentials via attachment secrets instead of hardcoding them: | |
| 87 | - | ||
| 88 | - | ```bash | |
| 89 | - | # Set database password securely | |
| 90 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 91 | - | ||
| 92 | - | # Verify | |
| 93 | - | gordon secrets list app.mydomain.com | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details. | |
| 97 | - | ||
| 98 | - | Build and push to Gordon: | |
| 99 | - | ```bash | |
| 100 | - | docker build -f postgres.Dockerfile -t my-postgres:latest . | |
| 101 | - | docker push registry.mydomain.com/my-postgres:latest | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Use in attachments: | |
| 105 | - | ```toml | |
| 106 | - | [attachments] | |
| 107 | - | "app.mydomain.com" = ["my-postgres:latest"] | |
| 108 | - | ``` | |
| 109 | - | ||
| 110 | - | ## Attachment Secrets | |
| 111 | - | ||
| 112 | - | Inject environment variables into attachment containers using the `--attachment` flag on secrets commands: | |
| 113 | - | ||
| 114 | - | ```bash | |
| 115 | - | # Set secrets for the postgres attachment | |
| 116 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 117 | - | ||
| 118 | - | # Set secrets for the redis attachment | |
| 119 | - | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret | |
| 120 | - | ||
| 121 | - | # View all secrets (domain + attachments) in a tree view | |
| 122 | - | gordon secrets list app.mydomain.com | |
| 123 | - | ||
| 124 | - | # Remove an attachment secret | |
| 125 | - | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`. | |
| 129 | - | ||
| 130 | - | Storage depends on your secrets backend: | |
| 131 | - | - **pass**: `gordon/env/attachments/<container-name>/<KEY>` | |
| 132 | - | - **sops/unsafe**: `gordon-<container-name>.env` files | |
| 133 | - | ||
| 134 | - | See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details. | |
| 135 | - | ||
| 136 | - | ## Shared Attachments with Network Groups | |
| 137 | - | ||
| 138 | - | Share services between multiple apps using network groups: | |
| 139 | - | ||
| 140 | - | ```toml | |
| 141 | - | [network_groups] | |
| 142 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 143 | - | ||
| 144 | - | [attachments] | |
| 145 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 146 | - | ``` | |
| 147 | - | ||
| 148 | - | Both `app.mydomain.com` and `api.mydomain.com` can access the shared services. | |
| 149 | - | ||
| 150 | - | ## Per-App vs Shared Attachments | |
| 151 | - | ||
| 152 | - | ### Per-App Attachments | |
| 153 | - | ||
| 154 | - | Each app gets its own isolated service instances: | |
| 155 | - | ||
| 156 | - | ```toml | |
| 157 | - | [attachments] | |
| 158 | - | "app.mydomain.com" = ["postgres:latest"] # App's own postgres | |
| 159 | - | "api.mydomain.com" = ["postgres:latest"] # API's own postgres | |
| 160 | - | ``` | |
| 161 | - | ||
| 162 | - | ### Shared Attachments | |
| 163 | - | ||
| 164 | - | Multiple apps share the same service instances: | |
| 165 | - | ||
| 166 | - | ```toml | |
| 167 | - | [network_groups] | |
| 168 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 169 | - | ||
| 170 | - | [attachments] | |
| 171 | - | "backend" = ["postgres:latest", "redis:latest"] # Shared by both | |
| 172 | - | ``` | |
| 173 | - | ||
| 174 | - | ### Mixed Approach | |
| 175 | - | ||
| 176 | - | ```toml | |
| 177 | - | [network_groups] | |
| 178 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 179 | - | ||
| 180 | - | [attachments] | |
| 181 | - | "backend" = ["redis:latest"] # Shared cache | |
| 182 | - | "app.mydomain.com" = ["postgres:latest"] # App's own database | |
| 183 | - | "api.mydomain.com" = ["postgres:latest"] # API's own database | |
| 184 | - | ``` | |
| 185 | - | ||
| 186 | - | ## Examples | |
| 187 | - | ||
| 188 | - | ### Web App with Database | |
| 189 | - | ||
| 190 | - | ```toml | |
| 191 | - | [routes] | |
| 192 | - | "app.mydomain.com" = "myapp:latest" | |
| 193 | - | ||
| 194 | - | [attachments] | |
| 195 | - | "app.mydomain.com" = ["postgres:18", "redis:7-alpine"] | |
| 196 | - | ``` | |
| 197 | - | ||
| 198 | - | ### Microservices with Shared Queue | |
| 199 | - | ||
| 200 | - | ```toml | |
| 201 | - | [routes] | |
| 202 | - | "orders.mydomain.com" = "orders-service:latest" | |
| 203 | - | "inventory.mydomain.com" = "inventory-service:latest" | |
| 204 | - | "notifications.mydomain.com" = "notifications-service:latest" | |
| 205 | - | ||
| 206 | - | [network_groups] | |
| 207 | - | "services" = ["orders.mydomain.com", "inventory.mydomain.com", "notifications.mydomain.com"] | |
| 208 | - | ||
| 209 | - | [attachments] | |
| 210 | - | "services" = ["rabbitmq:3-management"] | |
| 211 | - | "orders.mydomain.com" = ["orders-db:latest"] | |
| 212 | - | "inventory.mydomain.com" = ["inventory-db:latest"] | |
| 213 | - | ``` | |
| 214 | - | ||
| 215 | - | ### Custom Service Images | |
| 216 | - | ||
| 217 | - | Build custom service images with your configuration: | |
| 218 | - | ||
| 219 | - | ```dockerfile | |
| 220 | - | # my-postgres.Dockerfile | |
| 221 | - | FROM postgres:18 | |
| 222 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 223 | - | ENV POSTGRES_DB=production | |
| 224 | - | ENV POSTGRES_USER=appuser | |
| 225 | - | # Password should come from env file | |
| 226 | - | ``` | |
| 227 | - | ||
| 228 | - | ```dockerfile | |
| 229 | - | # my-redis.Dockerfile | |
| 230 | - | FROM redis:7-alpine | |
| 231 | - | VOLUME ["/data"] | |
| 232 | - | CMD ["redis-server", "--appendonly", "yes"] | |
| 233 | - | ``` | |
| 234 | - | ||
| 235 | - | ```toml | |
| 236 | - | [attachments] | |
| 237 | - | "app.mydomain.com" = ["my-postgres:latest", "my-redis:latest"] | |
| 238 | - | ``` | |
| 239 | - | ||
| 240 | - | ## Container Naming | |
| 241 | - | ||
| 242 | - | Attachment containers are named: | |
| 243 | - | - `gordon-<domain>-<image-name>` | |
| 244 | - | - Example: `gordon-app-mydomain-com-postgres` | |
| 245 | - | ||
| 246 | - | ## Labels | |
| 247 | - | ||
| 248 | - | Gordon adds labels to attachment containers: | |
| 249 | - | ||
| 250 | - | | Label | Value | | |
| 251 | - | |-------|-------| | |
| 252 | - | | `gordon.managed` | `true` | | |
| 253 | - | | `gordon.attachment` | `true` | | |
| 254 | - | | `gordon.attached-to` | Domain or group name | | |
| 255 | - | ||
| 256 | - | ## CLI Management | |
| 257 | - | ||
| 258 | - | Manage attachments via the CLI without editing configuration files. | |
| 259 | - | ||
| 260 | - | ### List Attachments | |
| 261 | - | ||
| 262 | - | ```bash | |
| 263 | - | # List all attachments | |
| 264 | - | gordon attachments list | |
| 265 | - | ||
| 266 | - | # List attachments for a specific domain or network group | |
| 267 | - | gordon attachments list app.mydomain.com | |
| 268 | - | gordon attachments list backend | |
| 269 | - | ||
| 270 | - | # Remote mode | |
| 271 | - | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | |
| 272 | - | ``` | |
| 273 | - | ||
| 274 | - | ### Add Attachments | |
| 275 | - | ||
| 276 | - | ```bash | |
| 277 | - | # Add attachment to a domain | |
| 278 | - | gordon attachments add app.mydomain.com postgres:18 | |
| 279 | - | ||
| 280 | - | # Add attachment to a network group | |
| 281 | - | gordon attachments add backend redis:7-alpine | |
| 282 | - | ||
| 283 | - | # Remote mode | |
| 284 | - | gordon attachments add app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 285 | - | ``` | |
| 286 | - | ||
| 287 | - | ### Remove Attachments | |
| 288 | - | ||
| 289 | - | ```bash | |
| 290 | - | # Remove attachment from a domain | |
| 291 | - | gordon attachments remove app.mydomain.com postgres:18 | |
| 292 | - | ||
| 293 | - | # Remove from network group | |
| 294 | - | gordon attachments remove backend redis:7-alpine | |
| 295 | - | ||
| 296 | - | # Remote mode | |
| 297 | - | gordon attachments remove app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 298 | - | ``` | |
| 299 | - | ||
| 300 | - | ### Alias | |
| 301 | - | ||
| 302 | - | The `gordon attach` command is an alias for `gordon attachments`: | |
| 303 | - | ||
| 304 | - | ```bash | |
| 305 | - | gordon attach list | |
| 306 | - | gordon attach add app.mydomain.com postgres:18 | |
| 307 | - | gordon attach remove app.mydomain.com postgres:18 | |
| 308 | - | ``` | |
| 309 | - | ||
| 310 | - | ## Related | |
| 311 | - | ||
| 312 | - | - [Network Isolation](./network-isolation.md) | |
| 313 | - | - [Network Groups](./network-groups.md) | |
| 314 | - | - [Routes](./routes.md) | |
| 315 | - | - [CLI Commands](/docs/cli/index.md) | |
M docs/config/auth.md +12 -3
| @@ -87,8 +87,8 @@ Admin scopes (for remote CLI): | |||
| 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 | | |
| @@ -143,10 +143,19 @@ gordon auth token generate --subject temp --expiry 30d | |||
| 143 | 143 | ||
| 144 | 144 | Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode: | |
| 145 | 145 | ||
| 146 | - | - `/admin/*` endpoints are not registered. | |
| 146 | + | - `/admin/*` endpoints are not registered on the TCP listener. | |
| 147 | 147 | - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`). | |
| 148 | 148 | - Remote registry and remote admin access are disabled. | |
| 149 | 149 | ||
| 150 | + | Local `gordon apps` commands keep working through an owner-only administration socket: | |
| 151 | + | ||
| 152 | + | - When `XDG_RUNTIME_DIR` is set, the daemon uses `$XDG_RUNTIME_DIR/gordon/admin.sock`; an invalid or unsafe preferred path fails closed and never falls back. When `XDG_RUNTIME_DIR` is unset, the daemon uses `~/.gordon/run/admin.sock`. The CLI probes `$XDG_RUNTIME_DIR/gordon/admin.sock` when set, then `/run/user/<uid>/gordon/admin.sock`, then `~/.gordon/run/admin.sock`, accepting only a safe owner-owned socket. | |
| 153 | + | - The runtime directory is `0700` and the socket `0600`, both owned by the effective user. A foreign owner, group/other permission bits, or a symlink makes the daemon refuse to start and the CLI refuse to connect; an existing live listener is never replaced. | |
| 154 | + | - The socket exposes only app administration and app log reads. The implicit `local-owner` principal receives exactly `admin:apps:read`, `admin:apps:write`, and `admin:logs:read`; configuration, auth, prune, route, backup, and volume routes are denied. | |
| 155 | + | - Unix platforms only. Never share, mount, or forward the socket: filesystem access to it is the credential. | |
| 156 | + | ||
| 157 | + | With neither a running daemon nor an explicit remote, app commands fail with `daemon-unavailable`. An explicit `--remote`/`GORDON_REMOTE` is authoritative and never falls back to the local socket. | |
| 158 | + | ||
| 150 | 159 | Use this only when Gordon is intended for local machine usage. | |
| 151 | 160 | ||
| 152 | 161 | ## Internal Registry Auth | |
D docs/config/auto-route.md +0 -281
| @@ -1,281 +0,0 @@ | |||
| 1 | - | # Auto Route Configuration | |
| 2 | - | ||
| 3 | - | Gordon supports two methods for automatic route creation: | |
| 4 | - | ||
| 5 | - | 1. **Image Name Detection** - Routes from domain-like image names | |
| 6 | - | 2. **Image Labels** - Routes from Dockerfile labels (recommended) | |
| 7 | - | ||
| 8 | - | ## Configuration | |
| 9 | - | ||
| 10 | - | ```toml | |
| 11 | - | [auto_route] | |
| 12 | - | enabled = true | |
| 13 | - | ``` | |
| 14 | - | ||
| 15 | - | ## Options | |
| 16 | - | ||
| 17 | - | | Option | Type | Default | Description | | |
| 18 | - | |--------|------|---------|-------------| | |
| 19 | - | | `enabled` | bool | `false` | Enable automatic route creation | | |
| 20 | - | ||
| 21 | - | ## How It Works | |
| 22 | - | ||
| 23 | - | When auto-route is enabled, Gordon automatically creates routes for images with domain-like names: | |
| 24 | - | ||
| 25 | - | ```bash | |
| 26 | - | # Push an image with a domain name | |
| 27 | - | docker push registry.mydomain.com/app.example.com:latest | |
| 28 | - | ``` | |
| 29 | - | ||
| 30 | - | Gordon automatically: | |
| 31 | - | 1. Detects `app.example.com` looks like a domain | |
| 32 | - | 2. Creates a route: `app.example.com` → `app.example.com:latest` | |
| 33 | - | 3. Deploys the container | |
| 34 | - | 4. Routes traffic to it | |
| 35 | - | ||
| 36 | - | ## Domain Detection | |
| 37 | - | ||
| 38 | - | Gordon recognizes image names as domains when they: | |
| 39 | - | ||
| 40 | - | - Contain at least one dot (`.`) | |
| 41 | - | - Don't start or end with a dot | |
| 42 | - | - Look like valid hostnames | |
| 43 | - | ||
| 44 | - | | Image Name | Detected as Domain? | | |
| 45 | - | |------------|-------------------| | |
| 46 | - | | `app.example.com` | Yes | | |
| 47 | - | | `myapp.dev` | Yes | | |
| 48 | - | | `staging.api.io` | Yes | | |
| 49 | - | | `myapp` | No (no dots) | | |
| 50 | - | | `.invalid` | No (starts with dot) | | |
| 51 | - | | `myapp.` | No (ends with dot) | | |
| 52 | - | ||
| 53 | - | ## Use Cases | |
| 54 | - | ||
| 55 | - | ### Simplified Deployment | |
| 56 | - | ||
| 57 | - | Without auto-route: | |
| 58 | - | ```toml | |
| 59 | - | # Must define each route in config | |
| 60 | - | [routes] | |
| 61 | - | "app.example.com" = "app.example.com:latest" | |
| 62 | - | ``` | |
| 63 | - | ||
| 64 | - | With auto-route: | |
| 65 | - | ```toml | |
| 66 | - | [auto_route] | |
| 67 | - | enabled = true | |
| 68 | - | # No routes needed - just push! | |
| 69 | - | ``` | |
| 70 | - | ||
| 71 | - | ```bash | |
| 72 | - | docker push registry.mydomain.com/app.example.com:latest | |
| 73 | - | # Route automatically created | |
| 74 | - | ``` | |
| 75 | - | ||
| 76 | - | ### Development Workflow | |
| 77 | - | ||
| 78 | - | ```bash | |
| 79 | - | # Create a new app instantly | |
| 80 | - | docker build -t myapp . | |
| 81 | - | docker tag myapp registry.mydomain.com/newapp.mydomain.com:latest | |
| 82 | - | docker push registry.mydomain.com/newapp.mydomain.com:latest | |
| 83 | - | # newapp.mydomain.com is now live! | |
| 84 | - | ``` | |
| 85 | - | ||
| 86 | - | ### Multi-Tenant SaaS | |
| 87 | - | ||
| 88 | - | ```bash | |
| 89 | - | # Quickly provision customer subdomains | |
| 90 | - | docker push registry.mydomain.com/acme.saas.com:latest | |
| 91 | - | docker push registry.mydomain.com/beta.saas.com:latest | |
| 92 | - | docker push registry.mydomain.com/gamma.saas.com:latest | |
| 93 | - | ``` | |
| 94 | - | ||
| 95 | - | ## Combining with Manual Routes | |
| 96 | - | ||
| 97 | - | Auto-route works alongside manual routes: | |
| 98 | - | ||
| 99 | - | ```toml | |
| 100 | - | [auto_route] | |
| 101 | - | enabled = true | |
| 102 | - | ||
| 103 | - | [routes] | |
| 104 | - | # Pinned versions override auto-route | |
| 105 | - | "api.mydomain.com" = "myapi:v2.1.0" | |
| 106 | - | ``` | |
| 107 | - | ||
| 108 | - | - `api.mydomain.com` uses the manually configured `myapi:v2.1.0` | |
| 109 | - | - Other domain-named images create automatic routes | |
| 110 | - | ||
| 111 | - | ## Priority Order | |
| 112 | - | ||
| 113 | - | 1. **Manual routes** in `[routes]` take precedence | |
| 114 | - | 2. **Auto-routes** are created for domain-named images without manual routes | |
| 115 | - | ||
| 116 | - | ## Examples | |
| 117 | - | ||
| 118 | - | ### Enable Auto-Route | |
| 119 | - | ||
| 120 | - | ```toml | |
| 121 | - | [auto_route] | |
| 122 | - | enabled = true | |
| 123 | - | ``` | |
| 124 | - | ||
| 125 | - | ### Development with Auto-Route | |
| 126 | - | ||
| 127 | - | ```toml | |
| 128 | - | [server] | |
| 129 | - | gordon_domain = "gordon.local" | |
| 130 | - | ||
| 131 | - | [auto_route] | |
| 132 | - | enabled = true | |
| 133 | - | ||
| 134 | - | # No routes defined - all come from image names | |
| 135 | - | ``` | |
| 136 | - | ||
| 137 | - | Usage: | |
| 138 | - | ```bash | |
| 139 | - | docker push gordon.local/myapp.local:latest | |
| 140 | - | docker push gordon.local/api.local:latest | |
| 141 | - | # Both routes created automatically | |
| 142 | - | ``` | |
| 143 | - | ||
| 144 | - | ### Production with Auto-Route | |
| 145 | - | ||
| 146 | - | ```toml | |
| 147 | - | [auto_route] | |
| 148 | - | enabled = true | |
| 149 | - | ||
| 150 | - | [routes] | |
| 151 | - | # Critical services with pinned versions | |
| 152 | - | "api.company.com" = "company-api:v2.1.0" | |
| 153 | - | "app.company.com" = "company-app:v1.5.0" | |
| 154 | - | ||
| 155 | - | # Other services can use auto-route | |
| 156 | - | ``` | |
| 157 | - | ||
| 158 | - | ## Limitations (Image Name Detection) | |
| 159 | - | ||
| 160 | - | - Auto-routes always use the pushed image tag | |
| 161 | - | - No support for HTTP-only routes (all HTTPS) | |
| 162 | - | - No automatic attachment configuration | |
| 163 | - | - Cannot pin versions (always uses pushed tag) | |
| 164 | - | ||
| 165 | - | For more control, define routes manually in `[routes]` or use image labels. | |
| 166 | - | ||
| 167 | - | --- | |
| 168 | - | ||
| 169 | - | ## Image Labels (Recommended) | |
| 170 | - | ||
| 171 | - | The preferred method for automatic routing uses Dockerfile labels. This gives you full control over routing behavior directly in your application. | |
| 172 | - | ||
| 173 | - | ### Supported Labels | |
| 174 | - | ||
| 175 | - | | Label | Description | Example | | |
| 176 | - | |-------|-------------|---------| | |
| 177 | - | | `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` | | |
| 178 | - | | `gordon.proxy.port` | Container port to proxy | `3000` | | |
| 179 | - | | `gordon.health` | Health check endpoint path | `/health` | | |
| 180 | - | | `gordon.env-file` | Path to .env file in image | `/app/.env.example` | | |
| 181 | - | ||
| 182 | - | ### Dockerfile Example | |
| 183 | - | ||
| 184 | - | ```dockerfile | |
| 185 | - | FROM node:20-alpine | |
| 186 | - | WORKDIR /app | |
| 187 | - | COPY . . | |
| 188 | - | RUN npm install | |
| 189 | - | ||
| 190 | - | # Gordon routing labels | |
| 191 | - | LABEL gordon.domains="myapp.example.com,www.myapp.example.com" | |
| 192 | - | LABEL gordon.proxy.port="3000" | |
| 193 | - | LABEL gordon.health="/api/health" | |
| 194 | - | LABEL gordon.env-file="/app/.env.example" | |
| 195 | - | ||
| 196 | - | EXPOSE 3000 | |
| 197 | - | CMD ["npm", "start"] | |
| 198 | - | ``` | |
| 199 | - | ||
| 200 | - | ### How It Works | |
| 201 | - | ||
| 202 | - | When you push an image with Gordon labels: | |
| 203 | - | ||
| 204 | - | 1. Gordon reads the `gordon.domains` label | |
| 205 | - | 2. Creates routes for each domain automatically | |
| 206 | - | 3. Uses `gordon.proxy.port` for the proxy target, otherwise auto-detects an exposed port preferring common HTTP ports | |
| 207 | - | 4. Configures health checks if `gordon.health` is set | |
| 208 | - | 5. Extracts environment variables from `gordon.env-file` if specified | |
| 209 | - | ||
| 210 | - | ### Label Priority | |
| 211 | - | ||
| 212 | - | Labels take precedence over image name detection: | |
| 213 | - | ||
| 214 | - | 1. **Labels present** → Use `gordon.domains` for routing | |
| 215 | - | 2. **No labels** → Fall back to image name detection (if enabled) | |
| 216 | - | 3. **Manual routes** → Always take highest priority | |
| 217 | - | ||
| 218 | - | ### Multi-Domain Example | |
| 219 | - | ||
| 220 | - | ```dockerfile | |
| 221 | - | # Single image serving multiple domains | |
| 222 | - | LABEL gordon.domains="api.example.com,api.staging.example.com" | |
| 223 | - | ``` | |
| 224 | - | ||
| 225 | - | Both domains will route to the same container. | |
| 226 | - | ||
| 227 | - | ### Environment File Extraction | |
| 228 | - | ||
| 229 | - | The `gordon.env-file` label tells Gordon where to find a template `.env` file: | |
| 230 | - | ||
| 231 | - | ```dockerfile | |
| 232 | - | # Include a .env.example in your image | |
| 233 | - | COPY .env.example /app/.env.example | |
| 234 | - | LABEL gordon.env-file="/app/.env.example" | |
| 235 | - | ``` | |
| 236 | - | ||
| 237 | - | Gordon will: | |
| 238 | - | 1. Extract the file from the image | |
| 239 | - | 2. Store it in the env directory | |
| 240 | - | 3. Use the variables for container deployment | |
| 241 | - | ||
| 242 | - | ### Best Practices | |
| 243 | - | ||
| 244 | - | 1. **Always set `gordon.proxy.port`** - Don't rely on EXPOSE detection | |
| 245 | - | 2. **Use `gordon.health`** - Enables reliable deployment verification | |
| 246 | - | 3. **Include `.env.example`** - Self-documenting environment requirements | |
| 247 | - | 4. **Separate domains with commas** - No spaces around commas | |
| 248 | - | ||
| 249 | - | ### Example: Complete Setup | |
| 250 | - | ||
| 251 | - | ```dockerfile | |
| 252 | - | FROM golang:1.25-alpine AS builder | |
| 253 | - | WORKDIR /app | |
| 254 | - | COPY . . | |
| 255 | - | RUN go build -o server | |
| 256 | - | ||
| 257 | - | FROM alpine:3.21 | |
| 258 | - | WORKDIR /app | |
| 259 | - | COPY --from=builder /app/server . | |
| 260 | - | COPY .env.example . | |
| 261 | - | ||
| 262 | - | # Gordon labels | |
| 263 | - | LABEL gordon.domains="api.mycompany.com" | |
| 264 | - | LABEL gordon.proxy.port="8080" | |
| 265 | - | LABEL gordon.health="/health" | |
| 266 | - | LABEL gordon.env-file="/app/.env.example" | |
| 267 | - | ||
| 268 | - | EXPOSE 8080 | |
| 269 | - | CMD ["./server"] | |
| 270 | - | ``` | |
| 271 | - | ||
| 272 | - | ```bash | |
| 273 | - | # Push and Gordon handles everything | |
| 274 | - | docker push registry.mycompany.com/api:latest | |
| 275 | - | # Route automatically created: api.mycompany.com → container:8080 | |
| 276 | - | ``` | |
| 277 | - | ||
| 278 | - | ## Related | |
| 279 | - | ||
| 280 | - | - [Routes](./routes.md) | |
| 281 | - | - [Configuration Overview](./index.md) | |
M docs/config/backups.md +16 -7
| @@ -5,6 +5,10 @@ Gordon has two backup flows: | |||
| 5 | 5 | - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem. | |
| 6 | 6 | - **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. | |
| 7 | 7 | ||
| 8 | + | Backups are identified by app, service, and the declared database or volume | |
| 9 | + | (see [Apps](../config/apps.md)). Domains are routing addresses and are never a | |
| 10 | + | backup identity. | |
| 11 | + | ||
| 8 | 12 | ## Database backups | |
| 9 | 13 | ||
| 10 | 14 | ```toml | |
| @@ -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) | |
M docs/config/env.md +9 -152
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Environment Variables | |
| 2 | 2 | ||
| 3 | - | Configure per-application environment variables using domain-based files. | |
| 3 | + | App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[service.secrets]` (values in pass, resolved at deploy). This page covers the installation `[env]` store location and the provider syntax used in env files. | |
| 4 | 4 | ||
| 5 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -13,75 +13,17 @@ dir = "~/.gordon/env" # Default location | |||
| 13 | 13 | ||
| 14 | 14 | | Option | Type | Default | Description | | |
| 15 | 15 | |--------|------|---------|-------------| | |
| 16 | - | | `dir` | string | `~/.gordon/env` | Directory containing .env files | | |
| 16 | + | | `dir` | string | `~/.gordon/env` | Directory for the installation secret store | | |
| 17 | 17 | ||
| 18 | 18 | ## How It Works | |
| 19 | 19 | ||
| 20 | - | Gordon loads environment variables from files named after the domain: | |
| 20 | + | The `[env]` directory backs the installation secret store: | |
| 21 | 21 | ||
| 22 | - | ``` | |
| 23 | - | ~/.gordon/env/ | |
| 24 | - | ├── app_mydomain_com.env # For app.mydomain.com | |
| 25 | - | ├── api_mydomain_com.env # For api.mydomain.com | |
| 26 | - | └── admin_mydomain_com.env # For admin.mydomain.com | |
| 27 | - | ``` | |
| 28 | - | ||
| 29 | - | **Naming convention:** Replace dots with underscores, add `.env` extension. | |
| 30 | - | ||
| 31 | - | | Domain | File Name | | |
| 32 | - | |--------|-----------| | |
| 33 | - | | `app.mydomain.com` | `app_mydomain_com.env` | | |
| 34 | - | | `api.company.io` | `api_company_io.env` | | |
| 35 | - | | `staging.app.dev` | `staging_app_dev.env` | | |
| 36 | - | ||
| 37 | - | ## Backend Behavior | |
| 38 | - | ||
| 39 | - | The `auth.secrets_backend` setting changes where route secrets live: | |
| 40 | - | ||
| 41 | - | ### Pass Backend | |
| 42 | - | ||
| 43 | - | - Env files are not used for route secrets. | |
| 44 | - | - Use `gordon secrets set` to store per-domain secrets in pass. | |
| 45 | - | - Existing `.env` files are migrated on startup and renamed to `.env.migrated`. | |
| 46 | - | ||
| 47 | - | ### SOPS or Unsafe Backend | |
| 48 | - | ||
| 49 | - | - Env files remain the source of truth. | |
| 50 | - | - Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |
| 51 | - | ||
| 52 | - | ## File Format | |
| 53 | - | ||
| 54 | - | Standard `.env` file format: | |
| 55 | - | ||
| 56 | - | ```bash | |
| 57 | - | # app_mydomain_com.env | |
| 58 | - | NODE_ENV=production | |
| 59 | - | PORT=3000 | |
| 60 | - | DATABASE_URL=postgresql://db:5432/myapp | |
| 61 | - | API_KEY=sk-1234567890 | |
| 62 | - | DEBUG=false | |
| 63 | - | ``` | |
| 64 | - | ||
| 65 | - | ## Variable Merging | |
| 66 | - | ||
| 67 | - | Variables are merged from multiple sources (lowest to highest priority): | |
| 22 | + | - With the `pass` backend, `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores). Env files are not used for secrets. | |
| 23 | + | - With the `pass` backend, Gordon imports eligible plaintext `.env` files into pass at startup and removes each source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon leaves the source file in place for operator review. | |
| 24 | + | - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |
| 68 | 25 | ||
| 69 | - | 1. **Dockerfile ENV** - Base defaults from image | |
| 70 | - | 2. **.env file** - Overrides Dockerfile values | |
| 71 | - | ||
| 72 | - | ```dockerfile | |
| 73 | - | # Dockerfile | |
| 74 | - | ENV NODE_ENV=development | |
| 75 | - | ENV PORT=3000 | |
| 76 | - | ``` | |
| 77 | - | ||
| 78 | - | ```bash | |
| 79 | - | # app_mydomain_com.env | |
| 80 | - | NODE_ENV=production # Overrides Dockerfile | |
| 81 | - | # PORT not set, uses 3000 from Dockerfile | |
| 82 | - | ``` | |
| 83 | - | ||
| 84 | - | Result: `NODE_ENV=production`, `PORT=3000` | |
| 26 | + | App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[service.secrets]` at deploy time (see [App Manifest](./apps.md)). | |
| 85 | 27 | ||
| 86 | 28 | ## Secret Provider Syntax | |
| 87 | 29 | ||
| @@ -110,98 +52,13 @@ STRIPE_KEY=${sops:secrets.yaml:stripe.api_key} | |||
| 110 | 52 | | pass | `${pass:<path>}` | `${pass:myapp/db-password}` | | |
| 111 | 53 | | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` | | |
| 112 | 54 | ||
| 113 | - | ## Examples | |
| 114 | - | ||
| 115 | - | ### Basic Application | |
| 116 | - | ||
| 117 | - | ```bash | |
| 118 | - | # app_mydomain_com.env | |
| 119 | - | NODE_ENV=production | |
| 120 | - | PORT=3000 | |
| 121 | - | LOG_LEVEL=info | |
| 122 | - | ``` | |
| 123 | - | ||
| 124 | - | ### Database Connection | |
| 125 | - | ||
| 126 | - | ```bash | |
| 127 | - | # ~/.gordon/env/api_mydomain_com.env | |
| 128 | - | DATABASE_HOST=postgres | |
| 129 | - | DATABASE_PORT=5432 | |
| 130 | - | DATABASE_NAME=myapi | |
| 131 | - | DATABASE_USER=apiuser | |
| 132 | - | DATABASE_PASSWORD=${pass:myapi/db-password} | |
| 133 | - | DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} | |
| 134 | - | ``` | |
| 135 | - | ||
| 136 | - | ### Full Production Setup | |
| 137 | - | ||
| 138 | - | ```bash | |
| 139 | - | # ~/.gordon/env/app_company_com.env | |
| 140 | - | # Application settings | |
| 141 | - | NODE_ENV=production | |
| 142 | - | PORT=3000 | |
| 143 | - | LOG_LEVEL=warn | |
| 144 | - | ||
| 145 | - | # Database | |
| 146 | - | DATABASE_URL=postgresql://postgres:5432/production | |
| 147 | - | DATABASE_PASSWORD=${pass:company/db-password} | |
| 148 | - | ||
| 149 | - | # Redis | |
| 150 | - | REDIS_URL=redis://redis:6379 | |
| 151 | - | ||
| 152 | - | # External APIs | |
| 153 | - | STRIPE_SECRET_KEY=${pass:company/stripe-secret} | |
| 154 | - | SENDGRID_API_KEY=${pass:company/sendgrid-key} | |
| 155 | - | ||
| 156 | - | # Auth | |
| 157 | - | JWT_SECRET=${pass:company/jwt-secret} | |
| 158 | - | SESSION_SECRET=${pass:company/session-secret} | |
| 159 | - | ||
| 160 | - | # Feature flags | |
| 161 | - | ENABLE_ANALYTICS=true | |
| 162 | - | MAINTENANCE_MODE=false | |
| 163 | - | ``` | |
| 164 | - | ||
| 165 | - | ### Development Environment | |
| 166 | - | ||
| 167 | - | ```bash | |
| 168 | - | # ~/.gordon/env/app_local.env | |
| 169 | - | NODE_ENV=development | |
| 170 | - | PORT=3000 | |
| 171 | - | LOG_LEVEL=debug | |
| 172 | - | DATABASE_URL=postgresql://postgres:5432/dev | |
| 173 | - | DEBUG=* | |
| 174 | - | ``` | |
| 175 | - | ||
| 176 | 55 | ## File Permissions | |
| 177 | 56 | ||
| 178 | - | Gordon creates env files with secure permissions: | |
| 57 | + | The env directory uses secure permissions: | |
| 179 | 58 | - Directory: `0700` (owner only) | |
| 180 | 59 | - Files: `0600` (owner only) | |
| 181 | 60 | ||
| 182 | - | Create files with proper permissions: | |
| 183 | - | ||
| 184 | - | ```bash | |
| 185 | - | mkdir -p ~/.gordon/env | |
| 186 | - | chmod 700 ~/.gordon/env | |
| 187 | - | touch ~/.gordon/env/app_mydomain_com.env | |
| 188 | - | chmod 600 ~/.gordon/env/app_mydomain_com.env | |
| 189 | - | ``` | |
| 190 | - | ||
| 191 | - | ## Auto-Creation | |
| 192 | - | ||
| 193 | - | Gordon creates empty env files automatically when deploying a new route. You can then edit the file to add your variables. | |
| 194 | - | ||
| 195 | - | ## Viewing Effective Environment | |
| 196 | - | ||
| 197 | - | To see what environment a container receives: | |
| 198 | - | ||
| 199 | - | ```bash | |
| 200 | - | docker inspect gordon-app-mydomain-com | jq '.[0].Config.Env' | |
| 201 | - | ``` | |
| 202 | - | ||
| 203 | 61 | ## Related | |
| 204 | 62 | ||
| 205 | 63 | - [Secrets Configuration](./secrets.md) | |
| 206 | - | - [Routes](./routes.md) | |
| 207 | - | - [Attachments](./attachments.md) | |
| 64 | + | - [App Manifest](./apps.md) | |
M docs/config/external-routes.md +1 -1
| @@ -79,5 +79,5 @@ vim ~/.config/gordon/gordon.toml | |||
| 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 -44
| @@ -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]`, app-like `[[services]]`, `[service_routes]`, `[auto_route]`, and `[previews]` are rejected at startup. | |
| 25 | + | ||
| 26 | + | > **Note:** `gordon_domain` is the canonical registry and Admin API host. | |
| 28 | 27 | > | |
| 29 | 28 | > For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename). | |
| 30 | 29 | ||
| @@ -87,12 +86,8 @@ max_size = 100 # MB before rotation | |||
| 87 | 86 | max_backups = 3 # Old files to keep | |
| 88 | 87 | max_age = 28 # Days to keep | |
| 89 | 88 | ||
| 90 | - | [logging.container_logs] | |
| 91 | - | enabled = true | |
| 92 | - | dir = "~/.gordon/logs/containers" # Default location | |
| 93 | - | max_size = 100 | |
| 94 | - | max_backups = 3 | |
| 95 | - | max_age = 28 | |
| 89 | + | # Workload logs are streamed directly from the container runtime with | |
| 90 | + | # `gordon apps logs APP --service SERVICE`. | |
| 96 | 91 | ||
| 97 | 92 | # Telemetry (OpenTelemetry) | |
| 98 | 93 | [telemetry] | |
| @@ -104,7 +99,7 @@ metrics = true # Export metrics | |||
| 104 | 99 | logs = true # Bridge zerolog to OTLP logs | |
| 105 | 100 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | |
| 106 | 101 | ||
| 107 | - | # Environment variables | |
| 102 | + | # Installation secret store location | |
| 108 | 103 | [env] | |
| 109 | 104 | dir = "~/.gordon/env" # Default location | |
| 110 | 105 | ||
| @@ -114,29 +109,15 @@ auto_create = true # Auto-create from Dockerfile VOLUME | |||
| 114 | 109 | prefix = "gordon" # Volume name prefix | |
| 115 | 110 | preserve = true # Keep volumes on container removal | |
| 116 | 111 | ||
| 117 | - | # Network isolation | |
| 112 | + | # Installation network policy (prefix filter for `gordon networks list`) | |
| 118 | 113 | [network_isolation] | |
| 119 | - | enabled = true # Per-app isolated networks | |
| 114 | + | enabled = true # Gordon-managed network policy | |
| 120 | 115 | network_prefix = "gordon" # Network name prefix | |
| 121 | 116 | internal = false # Set true to block direct egress from isolated networks | |
| 122 | 117 | ||
| 123 | - | # Auto-route | |
| 124 | - | [auto_route] | |
| 125 | - | enabled = false # Auto-create routes from image names | |
| 126 | - | ||
| 127 | - | # Routes (required) | |
| 128 | - | [routes] | |
| 129 | - | "app.mydomain.com" = "myapp:latest" | |
| 130 | - | "api.mydomain.com" = "myapi:v2.1.0" | |
| 131 | - | ||
| 132 | - | # Network groups | |
| 133 | - | [network_groups] | |
| 134 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 135 | - | ||
| 136 | - | # Attachments | |
| 137 | - | [attachments] | |
| 138 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 139 | - | "backend" = ["rabbitmq:latest"] | |
| 118 | + | # REMOVED in v2.50 (declare apps in standalone files, see ./apps.md): | |
| 119 | + | # [routes], [attachments], [network_groups], [[services]]-as-apps, | |
| 120 | + | # [service_routes], [auto_route], [previews] | |
| 140 | 121 | ||
| 141 | 122 | # Backups | |
| 142 | 123 | [backups] | |
| @@ -152,8 +133,8 @@ monthly = 12 | |||
| 152 | 133 | ||
| 153 | 134 | # Images | |
| 154 | 135 | [images] | |
| 155 | - | allowed_registries = [] # Explicit external registries allowed for deploy/attachments | |
| 156 | - | require_digest = false # Require digests for allowlisted external registries | |
| 136 | + | allowed_registries = [] # Additional exact registry hostname+port entries | |
| 137 | + | require_digest = false # Require SHA-256 digests for every image registry | |
| 157 | 138 | ||
| 158 | 139 | [images.prune] | |
| 159 | 140 | enabled = false | |
| @@ -161,6 +142,8 @@ schedule = "daily" | |||
| 161 | 142 | keep_last = 3 | |
| 162 | 143 | ``` | |
| 163 | 144 | ||
| 145 | + | Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry are allowed by default. Add private or other registries with exact hostname+port entries. This hostname policy does not enforce resolved IP destinations or runtime egress; see [Images](./images.md). | |
| 146 | + | ||
| 164 | 147 | ## Configuration Sections | |
| 165 | 148 | ||
| 166 | 149 | | Section | Description | Documentation | | |
| @@ -171,16 +154,13 @@ keep_last = 3 | |||
| 171 | 154 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 172 | 155 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 173 | 156 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | |
| 174 | - | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| 157 | + | | `[env]` | Installation secret store location | [Environment](./env.md) | | |
| 175 | 158 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | |
| 176 | - | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | |
| 177 | - | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 178 | - | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 159 | + | | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) | | |
| 179 | 160 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 180 | 161 | | `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) | | |
| 181 | - | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 182 | - | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 183 | - | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 162 | + | | App files (`<app>.toml`) | Declarative apps: services, hosts, secrets, volumes, backup targets | [App Manifest](./apps.md) | | |
| 163 | + | | `[backups]` | Database backup storage, scheduling, and retention | [Backups](./backups.md) | | |
| 184 | 164 | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | |
| 185 | 165 | | Security hardening | Security controls and recommended knobs | [Security Hardening](./security-hardening.md) | | |
| 186 | 166 | ||
| @@ -214,13 +194,11 @@ keep_last = 3 | |||
| 214 | 194 | | `logging.file.max_size` | `100` | | |
| 215 | 195 | | `logging.file.max_backups` | `3` | | |
| 216 | 196 | | `logging.file.max_age` | `28` | | |
| 217 | - | | `logging.container_logs.enabled` | `true` | | |
| 218 | 197 | | `volumes.auto_create` | `true` | | |
| 219 | 198 | | `volumes.prefix` | `"gordon"` | | |
| 220 | 199 | | `volumes.preserve` | `true` | | |
| 221 | 200 | | `network_isolation.enabled` | `true` | | |
| 222 | 201 | | `network_isolation.internal` | `false` | | |
| 223 | - | | `auto_route.enabled` | `false` | | |
| 224 | 202 | | `backups.enabled` | `false` | | |
| 225 | 203 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | |
| 226 | 204 | | `images.allowed_registries` | `[]` | | |
| @@ -236,7 +214,7 @@ keep_last = 3 | |||
| 236 | 214 | | `telemetry.logs` | `true` | | |
| 237 | 215 | | `telemetry.trace_sample_rate` | `1.0` | | |
| 238 | 216 | ||
| 239 | - | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only. | |
| 217 | + | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is not registered on the TCP listener and `/v2/*` is loopback-only. Local `gordon apps` commands discover the daemon's owner-only admin socket in `$XDG_RUNTIME_DIR/gordon`, `/run/user/<uid>/gordon`, or `~/.gordon/run`; the daemon itself uses the XDG location when configured and otherwise the home fallback. See [Authentication](./auth.md#local-only-mode). | |
| 240 | 218 | ||
| 241 | 219 | ## Hot Reload | |
| 242 | 220 | ||
| @@ -256,7 +234,6 @@ gordon reload | |||
| 256 | 234 | | `server.max_proxy_response_size` | | |
| 257 | 235 | | `server.max_concurrent_conns` | | |
| 258 | 236 | ||
| 259 | - | > **Note:** Routes are hot-reloaded from the config file. You can still use the API or CLI (`gordon routes add/update/remove`) for live route changes. | |
| 260 | 237 | ||
| 261 | 238 | ### Requires restart | |
| 262 | 239 | ||
| @@ -286,7 +263,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 286 | 263 | ## Related | |
| 287 | 264 | ||
| 288 | 265 | - [Server Configuration](./server.md) | |
| 289 | - | - [Routes Configuration](./routes.md) | |
| 266 | + | - [App Manifest](./apps.md) | |
| 290 | 267 | - [External Routes](./external-routes.md) | |
| 291 | 268 | - [Standalone Services](./services.md) | |
| 292 | 269 | - [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 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 logs -f | |
| 146 | + | gordon 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 +9 -154
| @@ -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,32 @@ 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: | |
| 95 | - | ||
| 96 | - | ```toml | |
| 97 | - | [network_isolation] | |
| 98 | - | enabled = true | |
| 99 | - | ||
| 100 | - | [attachments] | |
| 101 | - | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Your application connects using simple hostnames: | |
| 105 | - | ||
| 106 | - | ```python | |
| 107 | - | # These work within the isolated network | |
| 108 | - | db = connect("postgresql://postgres:5432/mydb") | |
| 109 | - | cache = connect("redis://redis:6379") | |
| 110 | - | ``` | |
| 111 | - | ||
| 112 | - | ## Examples | |
| 113 | - | ||
| 114 | - | ### Basic Isolation | |
| 115 | - | ||
| 116 | - | ```toml | |
| 117 | - | [network_isolation] | |
| 118 | - | enabled = true | |
| 119 | - | ||
| 120 | - | [routes] | |
| 121 | - | "app.mydomain.com" = "myapp:latest" | |
| 122 | - | "api.mydomain.com" = "myapi:latest" | |
| 123 | - | ||
| 124 | - | [attachments] | |
| 125 | - | "app.mydomain.com" = ["app-postgres:latest"] | |
| 126 | - | "api.mydomain.com" = ["api-postgres:latest"] | |
| 127 | - | ``` | |
| 128 | - | ||
| 129 | - | Each app gets its own network with its own database. | |
| 130 | - | ||
| 131 | - | ### Production Configuration | |
| 132 | - | ||
| 133 | - | ```toml | |
| 134 | - | [network_isolation] | |
| 135 | - | enabled = true | |
| 136 | - | network_prefix = "prod" | |
| 137 | - | ||
| 138 | - | [routes] | |
| 139 | - | "app.company.com" = "company-app:v2.1.0" | |
| 140 | - | "api.company.com" = "company-api:v1.5.0" | |
| 141 | - | "admin.company.com" = "admin-panel:v1.0.0" | |
| 142 | - | ``` | |
| 143 | - | ||
| 144 | - | Creates networks: | |
| 145 | - | - `prod-app-company-com` | |
| 146 | - | - `prod-api-company-com` | |
| 147 | - | - `prod-admin-company-com` | |
| 148 | - | ||
| 149 | - | ### Shared Services with Network Groups | |
| 150 | - | ||
| 151 | - | When apps need to communicate, use network groups: | |
| 152 | - | ||
| 153 | - | ```toml | |
| 154 | - | [network_isolation] | |
| 155 | - | enabled = true | |
| 156 | - | ||
| 157 | - | [network_groups] | |
| 158 | - | "backend" = ["app.mydomain.com", "api.mydomain.com"] | |
| 159 | - | ||
| 160 | - | [attachments] | |
| 161 | - | "backend" = ["shared-postgres:latest", "shared-redis:latest"] | |
| 162 | - | ``` | |
| 18 | + | | `enabled` | bool | `true` | Enable Gordon-managed network policy | | |
| 19 | + | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` | | |
| 20 | + | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. | | |
| 163 | 21 | ||
| 164 | - | Both apps share the `gordon-backend` network. | |
| 22 | + | Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership. | |
| 165 | 23 | ||
| 166 | 24 | ## Inspecting Networks | |
| 167 | 25 | ||
| 168 | - | View created networks: | |
| 26 | + | View Gordon-managed networks: | |
| 169 | 27 | ||
| 170 | 28 | ```bash | |
| 29 | + | gordon networks list | |
| 171 | 30 | docker network ls | grep gordon | |
| 172 | - | # gordon-app-mydomain-com | |
| 173 | - | # gordon-api-mydomain-com | |
| 174 | - | # gordon-backend | |
| 175 | 31 | ``` | |
| 176 | 32 | ||
| 177 | 33 | Inspect a network: | |
| 178 | 34 | ||
| 179 | 35 | ```bash | |
| 180 | - | docker network inspect gordon-app-mydomain-com | |
| 36 | + | docker network inspect <network-name> | |
| 181 | 37 | ``` | |
| 182 | 38 | ||
| 183 | 39 | ## Related | |
| 184 | 40 | ||
| 185 | - | - [Network Groups](./network-groups.md) | |
| 186 | - | - [Attachments](./attachments.md) | |
| 41 | + | - [App Manifest](./apps.md) | |
| 187 | 42 | - [Configuration Overview](./index.md) | |
D docs/config/preview.md +0 -273
| @@ -1,273 +0,0 @@ | |||
| 1 | - | # Preview Environments | |
| 2 | - | ||
| 3 | - | Push a branch, get a URL. Preview environments give every branch or pull request its own isolated deployment — automatically torn down when it expires. | |
| 4 | - | ||
| 5 | - | Gordon provisions a preview environment when a matching image tag is pushed to the registry. Each preview gets its own subdomain derived from the base route, lives for a configurable TTL, and is cleaned up automatically when the TTL expires. | |
| 6 | - | ||
| 7 | - | ## Configuration | |
| 8 | - | ||
| 9 | - | ```toml | |
| 10 | - | [auto.preview] | |
| 11 | - | enabled = true | |
| 12 | - | ttl = "48h" | |
| 13 | - | separator = "--" | |
| 14 | - | tag_patterns = ["preview-*", "pr-*"] | |
| 15 | - | data_copy = true | |
| 16 | - | env_copy = true | |
| 17 | - | ``` | |
| 18 | - | ||
| 19 | - | ## Options | |
| 20 | - | ||
| 21 | - | | Option | Type | Default | Description | | |
| 22 | - | |--------|------|---------|-------------| | |
| 23 | - | | `enabled` | bool | `false` | Enable automatic preview environment creation | | |
| 24 | - | | `ttl` | duration string | `"48h"` | How long a preview environment lives before automatic teardown | | |
| 25 | - | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | |
| 26 | - | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | |
| 27 | - | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | |
| 28 | - | | `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container | | |
| 29 | - | ||
| 30 | - | ### `ttl` Format | |
| 31 | - | ||
| 32 | - | The `ttl` field accepts Go duration strings: | |
| 33 | - | ||
| 34 | - | | Value | Meaning | | |
| 35 | - | |-------|---------| | |
| 36 | - | | `"24h"` | 24 hours | | |
| 37 | - | | `"48h"` | 48 hours (default) | | |
| 38 | - | | `"168h"` | 7 days | | |
| 39 | - | | `"0"` | Never expire (manual deletion only) | | |
| 40 | - | ||
| 41 | - | ### `tag_patterns` Matching | |
| 42 | - | ||
| 43 | - | Patterns use standard glob syntax. A tag must match at least one pattern in the list for a preview environment to be created. | |
| 44 | - | ||
| 45 | - | | Pattern | Matches | | |
| 46 | - | |---------|---------| | |
| 47 | - | | `"preview-*"` | `preview-my-feature`, `preview-fix-123` | | |
| 48 | - | | `"pr-*"` | `pr-42`, `pr-123` | | |
| 49 | - | | `"feat/*"` | `feat/new-api`, `feat/dark-mode` | | |
| 50 | - | ||
| 51 | - | ## Naming Scheme | |
| 52 | - | ||
| 53 | - | Preview domains are derived from the base route for the deployed image. Gordon combines the base domain, the separator, and a URL-safe slug of the image tag. | |
| 54 | - | ||
| 55 | - | ```text | |
| 56 | - | <app><separator><preview-name><rest-of-domain> | |
| 57 | - | ``` | |
| 58 | - | ||
| 59 | - | For example, with `separator = "--"` and base route `myapp.example.com`: | |
| 60 | - | ||
| 61 | - | | Image Tag | Preview Domain | | |
| 62 | - | |-----------|----------------| | |
| 63 | - | | `preview-my-feature` | `myapp--my-feature.example.com` | | |
| 64 | - | | `pr-42` | `myapp--42.example.com` | | |
| 65 | - | | `preview-fix-login` | `myapp--fix-login.example.com` | | |
| 66 | - | ||
| 67 | - | The tag prefix matched by `tag_patterns` is stripped to keep domains short. Slashes in tags are replaced with hyphens. | |
| 68 | - | ||
| 69 | - | ## CLI Usage | |
| 70 | - | ||
| 71 | - | ### List Preview Environments | |
| 72 | - | ||
| 73 | - | ```bash | |
| 74 | - | gordon preview list | |
| 75 | - | ``` | |
| 76 | - | ||
| 77 | - | Shows all active preview environments, their domains, TTL remaining, and source route. | |
| 78 | - | ||
| 79 | - | ### Create or Refresh a Preview | |
| 80 | - | ||
| 81 | - | Previews are created automatically on push. To manually create or reset the TTL of an existing preview: | |
| 82 | - | ||
| 83 | - | ```bash | |
| 84 | - | gordon preview create app.example.com --tag preview-my-feature | |
| 85 | - | ``` | |
| 86 | - | ||
| 87 | - | ### Extend a Preview | |
| 88 | - | ||
| 89 | - | Reset the TTL on an existing preview without redeploying: | |
| 90 | - | ||
| 91 | - | ```bash | |
| 92 | - | gordon preview extend myapp--my-feature.example.com | |
| 93 | - | gordon preview extend myapp--my-feature.example.com --ttl 24h | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | Without `--ttl`, the configured default TTL is applied from the current time. | |
| 97 | - | ||
| 98 | - | ### Delete a Preview | |
| 99 | - | ||
| 100 | - | ```bash | |
| 101 | - | gordon preview delete myapp--my-feature.example.com | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Stops the container, removes the route, and (unless `volumes.preserve = true`) removes any cloned volumes. | |
| 105 | - | ||
| 106 | - | ## CI Usage | |
| 107 | - | ||
| 108 | - | ### GitHub Actions | |
| 109 | - | ||
| 110 | - | Use tag-based deploys to trigger preview environments automatically. | |
| 111 | - | ||
| 112 | - | ```yaml | |
| 113 | - | name: Preview Environment | |
| 114 | - | ||
| 115 | - | on: | |
| 116 | - | pull_request: | |
| 117 | - | types: [opened, synchronize] | |
| 118 | - | ||
| 119 | - | jobs: | |
| 120 | - | deploy-preview: | |
| 121 | - | runs-on: ubuntu-latest | |
| 122 | - | steps: | |
| 123 | - | - uses: actions/checkout@v4 | |
| 124 | - | ||
| 125 | - | - name: Log in to Gordon registry | |
| 126 | - | run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ vars.GORDON_REGISTRY }} -u deploy --password-stdin | |
| 127 | - | ||
| 128 | - | - name: Build and push preview image | |
| 129 | - | env: | |
| 130 | - | TAG: pr-${{ github.event.pull_request.number }} | |
| 131 | - | run: | | |
| 132 | - | docker build -t ${{ vars.GORDON_REGISTRY }}/myapp:$TAG . | |
| 133 | - | docker push ${{ vars.GORDON_REGISTRY }}/myapp:$TAG | |
| 134 | - | ||
| 135 | - | - name: Comment preview URL | |
| 136 | - | uses: actions/github-script@v7 | |
| 137 | - | with: | |
| 138 | - | script: | | |
| 139 | - | github.rest.issues.createComment({ | |
| 140 | - | issue_number: context.issue.number, | |
| 141 | - | owner: context.repo.owner, | |
| 142 | - | repo: context.repo.repo, | |
| 143 | - | body: `Preview deployed: https://app--${{ github.event.pull_request.number }}.example.com` | |
| 144 | - | }) | |
| 145 | - | ``` | |
| 146 | - | ||
| 147 | - | Configure Gordon to match the `pr-*` tag convention: | |
| 148 | - | ||
| 149 | - | ```toml | |
| 150 | - | [auto.preview] | |
| 151 | - | enabled = true | |
| 152 | - | ttl = "72h" | |
| 153 | - | tag_patterns = ["pr-*"] | |
| 154 | - | ``` | |
| 155 | - | ||
| 156 | - | ### Cleanup on PR Close | |
| 157 | - | ||
| 158 | - | ```yaml | |
| 159 | - | name: Teardown Preview | |
| 160 | - | ||
| 161 | - | on: | |
| 162 | - | pull_request: | |
| 163 | - | types: [closed] | |
| 164 | - | ||
| 165 | - | jobs: | |
| 166 | - | teardown: | |
| 167 | - | runs-on: ubuntu-latest | |
| 168 | - | steps: | |
| 169 | - | - name: Delete preview environment | |
| 170 | - | env: | |
| 171 | - | PR_NUMBER: ${{ github.event.pull_request.number }} | |
| 172 | - | run: | | |
| 173 | - | gordon --server ${{ vars.GORDON_SERVER }} preview delete app--${PR_NUMBER}.example.com | |
| 174 | - | ``` | |
| 175 | - | ||
| 176 | - | ## Lifecycle | |
| 177 | - | ||
| 178 | - | ### Creation | |
| 179 | - | ||
| 180 | - | When a push matches a `tag_patterns` entry: | |
| 181 | - | ||
| 182 | - | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | |
| 183 | - | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | |
| 184 | - | 3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them) | |
| 185 | - | 4. The preview TTL timer starts | |
| 186 | - | 5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |
| 187 | - | ||
| 188 | - | ### TTL and Automatic Teardown | |
| 189 | - | ||
| 190 | - | Gordon checks preview TTLs on a background schedule. When a preview expires: | |
| 191 | - | ||
| 192 | - | 1. In-flight connections are drained | |
| 193 | - | 2. The container is stopped and removed | |
| 194 | - | 3. The route is removed from the proxy | |
| 195 | - | 4. Cloned volumes are removed (unless `volumes.preserve = true`) | |
| 196 | - | ||
| 197 | - | Use `gordon preview extend` to reset the timer without redeploying. | |
| 198 | - | ||
| 199 | - | ### Volume Cloning | |
| 200 | - | ||
| 201 | - | When `data_copy = true`, Gordon copies the named volumes attached to the base route into fresh volumes for the preview. This gives the preview a realistic dataset without sharing state with production. | |
| 202 | - | ||
| 203 | - | - Cloned volumes are prefixed with the preview slug | |
| 204 | - | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | |
| 205 | - | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | |
| 206 | - | ||
| 207 | - | ### Environment Variable Inheritance | |
| 208 | - | ||
| 209 | - | Preview containers receive environment variables from three sources, merged in this order (last wins): | |
| 210 | - | ||
| 211 | - | 1. **Dockerfile `ENV`** — baked into the image at build time | |
| 212 | - | 2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`) | |
| 213 | - | 3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy | |
| 214 | - | ||
| 215 | - | On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time. | |
| 216 | - | ||
| 217 | - | #### Overriding variables for a preview | |
| 218 | - | ||
| 219 | - | To change a variable for a specific preview, set it on the preview domain: | |
| 220 | - | ||
| 221 | - | ```bash | |
| 222 | - | gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com | |
| 223 | - | ``` | |
| 224 | - | ||
| 225 | - | This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones. | |
| 226 | - | ||
| 227 | - | #### Disabling env inheritance | |
| 228 | - | ||
| 229 | - | To start previews with a clean environment (Dockerfile `ENV` only): | |
| 230 | - | ||
| 231 | - | ```toml | |
| 232 | - | [auto.preview] | |
| 233 | - | env_copy = false | |
| 234 | - | ``` | |
| 235 | - | ||
| 236 | - | ## Example Config | |
| 237 | - | ||
| 238 | - | ```toml | |
| 239 | - | [server] | |
| 240 | - | gordon_domain = "registry.example.com" | |
| 241 | - | ||
| 242 | - | [auto_route] | |
| 243 | - | enabled = true | |
| 244 | - | ||
| 245 | - | [auto.preview] | |
| 246 | - | enabled = true | |
| 247 | - | ttl = "48h" | |
| 248 | - | separator = "--" | |
| 249 | - | tag_patterns = ["preview-*", "pr-*"] | |
| 250 | - | data_copy = true | |
| 251 | - | env_copy = true | |
| 252 | - | ||
| 253 | - | [routes] | |
| 254 | - | "app.example.com" = "myapp:latest" | |
| 255 | - | "api.example.com" = "myapi:latest" | |
| 256 | - | ||
| 257 | - | [volumes] | |
| 258 | - | auto_create = true | |
| 259 | - | preserve = false | |
| 260 | - | ``` | |
| 261 | - | ||
| 262 | - | With this config: | |
| 263 | - | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars | |
| 264 | - | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | |
| 265 | - | - Both previews expire after 48 hours and volumes are removed on teardown | |
| 266 | - | - Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain | |
| 267 | - | ||
| 268 | - | ## Related | |
| 269 | - | ||
| 270 | - | - [Routes](./routes.md) | |
| 271 | - | - [Auto Route](./auto-route.md) | |
| 272 | - | - [Volumes](./volumes.md) | |
| 273 | - | - [Configuration Overview](./index.md) | |
M docs/config/reference.md +14 -41
| @@ -83,12 +83,9 @@ max_size = 100 # Max size in MB before rotation | |||
| 83 | 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 | |
| @@ -137,18 +134,12 @@ drain_delay = "2s" # Wait after cache invalidation bef | |||
| 137 | 134 | [containers] | |
| 138 | 135 | security_profile = "compat" # "compat" or "strict" | |
| 139 | 136 | ||
| 140 | - | # ============================================================================= | |
| 141 | - | # AUTO-ROUTE | |
| 142 | - | # ============================================================================= | |
| 143 | - | [auto_route] | |
| 144 | - | enabled = false # Create routes from image labels automatically | |
| 145 | - | ||
| 146 | 137 | # ============================================================================= | |
| 147 | 138 | # NETWORK ISOLATION | |
| 148 | 139 | # ============================================================================= | |
| 149 | 140 | [network_isolation] | |
| 150 | - | enabled = true # Enable per-app Docker networks | |
| 151 | - | network_prefix = "gordon" # Prefix for created networks | |
| 141 | + | enabled = true # Installation network policy for Gordon-managed networks | |
| 142 | + | network_prefix = "gordon" # Prefix filter for `gordon networks list` | |
| 152 | 143 | internal = false # Create Docker internal networks (blocks direct egress) | |
| 153 | 144 | ||
| 154 | 145 | # ============================================================================= | |
| @@ -159,13 +150,9 @@ auto_create = true # Auto-create volumes from Dockerfi | |||
| 159 | 150 | prefix = "gordon" # Volume name prefix | |
| 160 | 151 | preserve = true # Keep volumes when containers are removed | |
| 161 | 152 | ||
| 162 | - | # ============================================================================= | |
| 163 | - | # ROUTES | |
| 164 | - | # ============================================================================= | |
| 165 | - | [routes] | |
| 166 | - | # "domain.com" = { image = "image:tag" } | |
| 167 | - | # "insecure.domain.com" = { image = "image:tag", https = false } | |
| 168 | - | # Legacy "http://domain.com" keys are read for compatibility and rewritten on save. | |
| 153 | + | # REMOVED in v2.50: [routes], [attachments], [network_groups], | |
| 154 | + | # [[services]]-as-apps, [service_routes], [auto_route], [previews]. | |
| 155 | + | # Declare apps in standalone files (see ./apps.md). | |
| 169 | 156 | ||
| 170 | 157 | # ============================================================================= | |
| 171 | 158 | # EXTERNAL ROUTES | |
| @@ -195,18 +182,6 @@ preserve = true # Keep volumes when containers are | |||
| 195 | 182 | # publish = "127.0.0.1:38016" | |
| 196 | 183 | # trusted_cidrs = ["100.64.0.0/10"] | |
| 197 | 184 | ||
| 198 | - | # ============================================================================= | |
| 199 | - | # NETWORK GROUPS | |
| 200 | - | # ============================================================================= | |
| 201 | - | [network_groups] | |
| 202 | - | # "group-name" = ["domain1.com", "domain2.com"] | |
| 203 | - | ||
| 204 | - | # ============================================================================= | |
| 205 | - | # ATTACHMENTS | |
| 206 | - | # ============================================================================= | |
| 207 | - | [attachments] | |
| 208 | - | # "domain-or-group" = ["image1:tag", "image2:tag"] | |
| 209 | - | ||
| 210 | 185 | # ============================================================================= | |
| 211 | 186 | # BACKUPS | |
| 212 | 187 | # ============================================================================= | |
| @@ -225,6 +200,8 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 225 | 200 | # IMAGES | |
| 226 | 201 | # ============================================================================= | |
| 227 | 202 | [images] | |
| 203 | + | # Defaults: docker.io/registry-1.docker.io, ghcr.io, quay.io, Gordon registry. | |
| 204 | + | # Add private or other registries as exact hostname+port entries. | |
| 228 | 205 | allowed_registries = [] | |
| 229 | 206 | require_digest = false | |
| 230 | 207 | ||
| @@ -265,7 +242,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 265 | 242 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 266 | 243 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 267 | 244 | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |
| 268 | - | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 245 | + | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, TCP `/admin/*` not registered, owner-only admin socket for local `gordon apps`) | | |
| 269 | 246 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 270 | 247 | | `auth.token_expiry` | `"30d"` | 30 days | | |
| 271 | 248 | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | |
| @@ -280,10 +257,6 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 280 | 257 | | `logging.file.max_size` | `100` | 100 MB | | |
| 281 | 258 | | `logging.file.max_backups` | `3` | Keep 3 old files | | |
| 282 | 259 | | `logging.file.max_age` | `28` | 28 days | | |
| 283 | - | | `logging.container_logs.enabled` | `true` | Container logs enabled | | |
| 284 | - | | `logging.container_logs.max_size` | `100` | 100 MB | | |
| 285 | - | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 286 | - | | `logging.container_logs.max_age` | `28` | 28 days | | |
| 287 | 260 | | `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled | | |
| 288 | 261 | | `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) | | |
| 289 | 262 | | `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) | | |
| @@ -307,7 +280,6 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 307 | 280 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | |
| 308 | 281 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | |
| 309 | 282 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | |
| 310 | - | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 311 | 283 | | `network_isolation.enabled` | `true` | Network isolation enabled | | |
| 312 | 284 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| 313 | 285 | | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress | | |
| @@ -342,8 +314,8 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 342 | 314 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | |
| 343 | 315 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | |
| 344 | 316 | | `backups.retention.monthly` | `0` | Keep no monthly backups by default | | |
| 345 | - | | `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected | | |
| 346 | - | | `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries | | |
| 317 | + | | `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. | | |
| 318 | + | | `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon | | |
| 347 | 319 | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | |
| 348 | 320 | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | |
| 349 | 321 | | `images.prune.keep_last` | `3` | Number of recent tags kept per repository | | |
| @@ -419,5 +391,6 @@ gordon serve | |||
| 419 | 391 | - [Telemetry](./telemetry.md) | |
| 420 | 392 | - [Network Isolation](./network-isolation.md) | |
| 421 | 393 | - [Volumes](./volumes.md) | |
| 394 | + | - [App Manifest](./apps.md) | |
| 422 | 395 | - [Standalone Services](./services.md) | |
| 423 | 396 | - [Images](./images.md) | |
D docs/config/routes.md +0 -206
| @@ -1,206 +0,0 @@ | |||
| 1 | - | # Routes Configuration | |
| 2 | - | ||
| 3 | - | Routes map hostnames to container images. | |
| 4 | - | ||
| 5 | - | ## Configuration | |
| 6 | - | ||
| 7 | - | ```toml | |
| 8 | - | [routes] | |
| 9 | - | "app.mydomain.com" = { image = "myapp:latest" } | |
| 10 | - | "api.mydomain.com" = { image = "myapi:v2.1.0" } | |
| 11 | - | "admin.mydomain.com" = { image = "admin-panel:v1.0.0" } | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | ## Syntax | |
| 15 | - | ||
| 16 | - | ```toml | |
| 17 | - | [routes] | |
| 18 | - | "<domain>" = { image = "<image>:<tag>" } | |
| 19 | - | ``` | |
| 20 | - | ||
| 21 | - | | Component | Description | | |
| 22 | - | |-----------|-------------| | |
| 23 | - | | `domain` | Public, fully qualified domain name | | |
| 24 | - | | `image` | Full container image reference, including tag | | |
| 25 | - | | `https` | Optional; add `false` for HTTP-only routes | | |
| 26 | - | ||
| 27 | - | Legacy `http://...` route keys are still read for backward compatibility and rewritten on the next save. | |
| 28 | - | ||
| 29 | - | ## Route Types | |
| 30 | - | ||
| 31 | - | ### HTTPS Routes (Default) | |
| 32 | - | ||
| 33 | - | Standard routes expect HTTPS traffic (terminated by Cloudflare or similar): | |
| 34 | - | ||
| 35 | - | ```toml | |
| 36 | - | [routes] | |
| 37 | - | "app.mydomain.com" = { image = "myapp:latest" } | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | Route domains must be plain hostnames. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals. | |
| 41 | - | ||
| 42 | - | ### Development Routes | |
| 43 | - | ||
| 44 | - | For local testing, use a hostname you can resolve yourself: | |
| 45 | - | ||
| 46 | - | ```toml | |
| 47 | - | [routes] | |
| 48 | - | "dev-app.example.com" = { image = "internal-app:latest", https = false } | |
| 49 | - | ``` | |
| 50 | - | ||
| 51 | - | ## Version Strategies | |
| 52 | - | ||
| 53 | - | ### Latest Tag | |
| 54 | - | ||
| 55 | - | Always deploy the most recent push: | |
| 56 | - | ||
| 57 | - | ```toml | |
| 58 | - | [routes] | |
| 59 | - | "app.mydomain.com" = { image = "myapp:latest" } | |
| 60 | - | ``` | |
| 61 | - | ||
| 62 | - | ### Pinned Version | |
| 63 | - | ||
| 64 | - | Deploy a specific version: | |
| 65 | - | ||
| 66 | - | ```toml | |
| 67 | - | [routes] | |
| 68 | - | "app.mydomain.com" = { image = "myapp:v2.1.0" } | |
| 69 | - | ``` | |
| 70 | - | ||
| 71 | - | Update the config to deploy a new version: | |
| 72 | - | ||
| 73 | - | ```toml | |
| 74 | - | [routes] | |
| 75 | - | "app.mydomain.com" = { image = "myapp:v2.2.0" } # Changed | |
| 76 | - | ``` | |
| 77 | - | ||
| 78 | - | ### Semantic Versioning | |
| 79 | - | ||
| 80 | - | Use different routes for different versions: | |
| 81 | - | ||
| 82 | - | ```toml | |
| 83 | - | [routes] | |
| 84 | - | "app.mydomain.com" = { image = "myapp:v2.1.0" } # Production | |
| 85 | - | "staging.mydomain.com" = { image = "myapp:staging" } # Staging | |
| 86 | - | "canary.mydomain.com" = { image = "myapp:canary" } # Canary | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | ## Multiple Routes | |
| 90 | - | ||
| 91 | - | ### Same Image, Different Domains | |
| 92 | - | ||
| 93 | - | ```toml | |
| 94 | - | [routes] | |
| 95 | - | "app.mydomain.com" = { image = "myapp:latest" } | |
| 96 | - | "www.mydomain.com" = { image = "myapp:latest" } | |
| 97 | - | "mydomain.com" = { image = "myapp:latest" } | |
| 98 | - | ``` | |
| 99 | - | ||
| 100 | - | ### Multiple Services | |
| 101 | - | ||
| 102 | - | ```toml | |
| 103 | - | [routes] | |
| 104 | - | "app.mydomain.com" = { image = "frontend:latest" } | |
| 105 | - | "api.mydomain.com" = { image = "backend:v2.1.0" } | |
| 106 | - | "docs.mydomain.com" = { image = "documentation:latest" } | |
| 107 | - | "status.mydomain.com" = { image = "status-page:v1.0.0" } | |
| 108 | - | ``` | |
| 109 | - | ||
| 110 | - | ## How Routing Works | |
| 111 | - | ||
| 112 | - | 1. Request arrives for `app.mydomain.com` | |
| 113 | - | 2. Gordon looks up the route in configuration | |
| 114 | - | 3. Finds running container for `myapp:latest` | |
| 115 | - | 4. Proxies request to container's exposed port | |
| 116 | - | 5. Returns response to client | |
| 117 | - | ||
| 118 | - | ``` | |
| 119 | - | Client ─> Gordon Proxy ─> Container | |
| 120 | - | (port 80) (exposed port) | |
| 121 | - | ``` | |
| 122 | - | ||
| 123 | - | ## Deployment Flow | |
| 124 | - | ||
| 125 | - | When you push an image: | |
| 126 | - | ||
| 127 | - | ```bash | |
| 128 | - | docker push registry.mydomain.com/myapp:latest | |
| 129 | - | ``` | |
| 130 | - | ||
| 131 | - | 1. Gordon receives the image | |
| 132 | - | 2. Checks routes for any that use `myapp:latest` | |
| 133 | - | 3. Deploys new container for each matching route | |
| 134 | - | 4. Updates proxy to route traffic to new container | |
| 135 | - | 5. Stops old container | |
| 136 | - | ||
| 137 | - | ## Route Changes | |
| 138 | - | ||
| 139 | - | Route changes in the config file are hot-reloaded automatically: | |
| 140 | - | ||
| 141 | - | 1. Edit `[routes]` in `gordon.toml` | |
| 142 | - | 2. Save the file | |
| 143 | - | 3. Gordon reloads the updated routes and proxy config | |
| 144 | - | ||
| 145 | - | Legacy `http://...` route keys are still read here for backward compatibility and rewritten the next time Gordon saves the config. | |
| 146 | - | ||
| 147 | - | You can still manage routes with the API or CLI if you prefer live mutations: | |
| 148 | - | ||
| 149 | - | ```bash | |
| 150 | - | gordon routes add newapp.mydomain.com newapp:latest | |
| 151 | - | gordon routes add app.mydomain.com myapp:v2.2.0 | |
| 152 | - | gordon routes remove oldapp.mydomain.com | |
| 153 | - | ``` | |
| 154 | - | ||
| 155 | - | ## Examples | |
| 156 | - | ||
| 157 | - | ### Development Setup | |
| 158 | - | ||
| 159 | - | ```toml | |
| 160 | - | [routes] | |
| 161 | - | "dev-app.example.com" = { image = "myapp:latest" } | |
| 162 | - | "dev-api.example.com" = { image = "myapi:latest" } | |
| 163 | - | ``` | |
| 164 | - | ||
| 165 | - | Add to `/etc/hosts`: | |
| 166 | - | ||
| 167 | - | ```text | |
| 168 | - | 127.0.0.1 dev-app.example.com dev-api.example.com registry.example.com | |
| 169 | - | ``` | |
| 170 | - | ||
| 171 | - | ### Production Setup | |
| 172 | - | ||
| 173 | - | ```toml | |
| 174 | - | [routes] | |
| 175 | - | "app.company.com" = { image = "company-app:v2.1.0" } | |
| 176 | - | "api.company.com" = { image = "company-api:v1.5.2" } | |
| 177 | - | "admin.company.com" = { image = "admin-panel:v1.0.1" } | |
| 178 | - | "docs.company.com" = { image = "company-docs:latest" } | |
| 179 | - | ``` | |
| 180 | - | ||
| 181 | - | ### Multi-Tenant SaaS | |
| 182 | - | ||
| 183 | - | ```toml | |
| 184 | - | [routes] | |
| 185 | - | # Platform services | |
| 186 | - | "app.saas-platform.com" = { image = "saas-frontend:v2.1.0" } | |
| 187 | - | "api.saas-platform.com" = { image = "saas-api:v3.2.1" } | |
| 188 | - | ||
| 189 | - | # Customer subdomains | |
| 190 | - | "acme.saas-platform.com" = { image = "saas-app:v2.1.0" } | |
| 191 | - | "beta.saas-platform.com" = { image = "saas-app:v2.1.0" } | |
| 192 | - | ||
| 193 | - | # Customer custom domains | |
| 194 | - | "portal.acme-corp.com" = { image = "saas-app:v2.1.0" } | |
| 195 | - | ``` | |
| 196 | - | ||
| 197 | - | ## External Services | |
| 198 | - | ||
| 199 | - | For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md). | |
| 200 | - | ||
| 201 | - | ## Related | |
| 202 | - | ||
| 203 | - | - [Configuration Overview](./index.md) | |
| 204 | - | - [Auto Route](./auto-route.md) | |
| 205 | - | - [Attachments](./attachments.md) | |
| 206 | - | - [External Routes](./external-routes.md) | |
M docs/config/secrets.md +17 -43
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Secrets Configuration | |
| 2 | 2 | ||
| 3 | - | Configure how Gordon stores and retrieves sensitive data. | |
| 3 | + | Configure how Gordon stores and retrieves sensitive data. Two separate stores exist: installation secrets (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`). | |
| 4 | 4 | ||
| 5 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -52,11 +52,9 @@ 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 | |
| 55 | + | **Installation secrets storage:** | |
| 56 | + | - `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |
| 57 | + | - Eligible plaintext `.env` files are imported into pass at startup and removed only after all entries are stored successfully; conflicts or failures leave the source file for operator review | |
| 60 | 58 | ||
| 61 | 59 | ### SOPS | |
| 62 | 60 | ||
| @@ -79,7 +77,6 @@ sops secrets.yaml | |||
| 79 | 77 | ||
| 80 | 78 | **Usage in env files:** | |
| 81 | 79 | ```bash | |
| 82 | - | # ~/.gordon/env/app_mydomain_com.env | |
| 83 | 80 | API_SECRET=${sops:secrets.yaml:api.secret} | |
| 84 | 81 | DB_PASSWORD=${sops:secrets.yaml:database.password} | |
| 85 | 82 | ``` | |
| @@ -94,15 +91,6 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 94 | 91 | - Path traversal (`..`) is blocked | |
| 95 | 92 | - Only relative paths from your config directory are allowed | |
| 96 | 93 | ||
| 97 | - | **Route secrets storage:** | |
| 98 | - | - Domain secrets stay in `.env` files | |
| 99 | - | - Use `${sops:...}` syntax to resolve encrypted values | |
| 100 | - | ||
| 101 | - | **Attachment secrets storage:** | |
| 102 | - | - Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files | |
| 103 | - | - Use `${sops:...}` syntax inside attachment env files to resolve encrypted values | |
| 104 | - | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |
| 105 | - | ||
| 106 | 94 | ### Unsafe (Development Only) | |
| 107 | 95 | ||
| 108 | 96 | Stores secrets as plain text files: | |
| @@ -120,11 +108,6 @@ secrets_backend = "unsafe" | |||
| 120 | 108 | │ └── token_secret | |
| 121 | 109 | ``` | |
| 122 | 110 | ||
| 123 | - | **Attachment secrets:** | |
| 124 | - | - Stored as `gordon-<container-name>.env` files in the env directory | |
| 125 | - | - Example: `gordon-app__mydomain__com-postgres.env` | |
| 126 | - | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |
| 127 | - | ||
| 128 | 111 | **Usage:** | |
| 129 | 112 | ```bash | |
| 130 | 113 | # Create secret | |
| @@ -151,9 +134,19 @@ API_KEY=${pass:myapp/api-key} | |||
| 151 | 134 | ```bash | |
| 152 | 135 | # ${sops:<file>:<key.path>} | |
| 153 | 136 | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 154 | - | API_SECRET=${sops:production.yaml:api.secret.key} | |
| 137 | + | API_SECRET=${sops:production.yaml:api.key} | |
| 155 | 138 | ``` | |
| 156 | 139 | ||
| 140 | + | ## App Secret Values | |
| 141 | + | ||
| 142 | + | App secret names are registered in the app manifest (`[service.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | |
| 143 | + | ||
| 144 | + | ```bash | |
| 145 | + | gordon apps secrets set blog --service web DATABASE_URL=... | |
| 146 | + | ``` | |
| 147 | + | ||
| 148 | + | Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md). | |
| 149 | + | ||
| 157 | 150 | ## Examples | |
| 158 | 151 | ||
| 159 | 152 | ### Production with Pass | |
| @@ -182,29 +175,10 @@ enabled = false | |||
| 182 | 175 | secrets_backend = "unsafe" | |
| 183 | 176 | ``` | |
| 184 | 177 | ||
| 185 | - | ### Enterprise with SOPS | |
| 186 | - | ||
| 187 | - | ```toml | |
| 188 | - | [auth] | |
| 189 | - | enabled = true | |
| 190 | - | secrets_backend = "sops" | |
| 191 | - | token_secret = "gordon/auth/token_secret" | |
| 192 | - | ``` | |
| 193 | - | ||
| 194 | - | Environment file: | |
| 195 | - | ```bash | |
| 196 | - | # ~/.gordon/env/app_company_com.env | |
| 197 | - | NODE_ENV=production | |
| 198 | - | DATABASE_URL=postgresql://db:5432/app | |
| 199 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 200 | - | API_KEY=${sops:secrets.yaml:api.key} | |
| 201 | - | JWT_SECRET=${sops:secrets.yaml:jwt.secret} | |
| 202 | - | ``` | |
| 203 | - | ||
| 204 | 178 | ## Security Recommendations | |
| 205 | 179 | ||
| 206 | 180 | 1. **Production**: Always use `pass` or `sops` backend | |
| 207 | - | 2. **Never commit**: Don't commit unencrypted secrets to git | |
| 181 | + | 2. **Never commit**: Don't commit unencrypted secrets to git (app files must never contain secret values) | |
| 208 | 182 | 3. **Rotate regularly**: Regenerate tokens and passwords periodically | |
| 209 | 183 | 4. **Least privilege**: Use separate secrets per environment | |
| 210 | 184 | 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security | |
| @@ -212,5 +186,5 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret} | |||
| 212 | 186 | ## Related | |
| 213 | 187 | ||
| 214 | 188 | - [Authentication](./auth.md) | |
| 215 | - | - [Environment Variables](./env.md) | |
| 189 | + | - [App Manifest](./apps.md) | |
| 216 | 190 | - [Configuration Overview](./index.md) | |
M docs/config/security-hardening.md +24 -12
| @@ -24,37 +24,40 @@ Container and deploy logs can include environment-derived output. Gordon gates l | |||
| 24 | 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 | + | ## Pass import plaintext handling | |
| 41 | 44 | ||
| 42 | - | When Gordon migrates legacy plaintext `.env` files into `pass`, it removes the plaintext source after a successful migration and does not leave `.env.migrated` copies by default. If pass entries already exist, migration fails closed and leaves the plaintext file in place for manual operator review rather than deleting potentially unique values. | |
| 45 | + | With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review. | |
| 43 | 46 | ||
| 44 | 47 | ## External image registries | |
| 45 | 48 | ||
| 46 | - | Gordon's configured registry is always allowed. Explicit external registries must be allowlisted: | |
| 49 | + | Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly: | |
| 47 | 50 | ||
| 48 | 51 | ```toml | |
| 49 | 52 | [images] | |
| 50 | - | allowed_registries = ["docker.io", "ghcr.io", "registry.example.com:5000"] | |
| 53 | + | allowed_registries = ["registry.internal:5000"] | |
| 51 | 54 | require_digest = true | |
| 52 | 55 | ``` | |
| 53 | 56 | ||
| 54 | - | - Empty `allowed_registries` rejects explicit external registries. | |
| 55 | - | - `localhost`, loopback, private, link-local, unspecified, and metadata-style registries are rejected. | |
| 56 | - | - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`. | |
| 57 | - | - Include ports in allowlist entries when the registry uses a non-default port. | |
| 57 | + | - `docker.io` and Docker Hub's canonical pull host `registry-1.docker.io` are equivalent. | |
| 58 | + | - Other registries are accepted only when their exact hostname+port is configured. Allowlisting does not configure credentials; external resolution and pulls are anonymous unless the runtime already has suitable access. | |
| 59 | + | - `require_digest = true` requires every image, including Gordon registry images, to use `@sha256:<64 hex chars>`. | |
| 60 | + | - The allowlist restricts hostnames, not resolved IPs. It cannot prove a DNS hostname is non-private and does not replace firewall or runtime egress controls. | |
| 58 | 61 | ||
| 59 | 62 | ## Smart TCP Raw Fallback | |
| 60 | 63 | ||
| @@ -95,7 +98,16 @@ enabled = true | |||
| 95 | 98 | internal = true | |
| 96 | 99 | ``` | |
| 97 | 100 | ||
| 98 | - | `internal = false` remains the compatibility default because some applications and attachments need direct egress during startup. | |
| 101 | + | `internal = false` remains the compatibility default because some applications need direct egress during startup. | |
| 102 | + | ||
| 103 | + | Every app container joins an incarnation-owned private network derived from the app's internal UUID; shared memberships come only from explicit `[[network.shared]]` declarations, and memory/CPU/PID limits apply on every create and recovery path. | |
| 104 | + | ||
| 105 | + | ## Registry exposure | |
| 106 | + | ||
| 107 | + | - With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry. | |
| 108 | + | - With `auth.enabled = false`, the registry is local-only: the public proxy refuses registry-domain requests, and the registry handler accepts only direct loopback connections carrying the instance credentials. | |
| 109 | + | - Registry requests are parsed once into a validated operation used by both authorization and dispatch, so the repository a token is checked against is exactly the repository served. | |
| 110 | + | - Blob and upload access is repository-scoped: an upload UUID is usable only by the repository that started it, and a blob is served only to a repository that completed an upload of that digest. | |
| 99 | 111 | ||
| 100 | 112 | ## Container runtime profile | |
| 101 | 113 | ||
M docs/config/server.md +8 -11
| @@ -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,10 @@ 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 | + | ├── env/ # Installation secret-store data | |
| 224 | + | ├── logs/ # Gordon process and file-based access logs | |
| 227 | 225 | │ ├── gordon.log | |
| 228 | - | │ ├── proxy.log | |
| 229 | - | │ └── containers/ | |
| 226 | + | │ └── access.log | |
| 230 | 227 | └── secrets/ # Secrets (unsafe backend only) | |
| 231 | 228 | ``` | |
| 232 | 229 | ||
| @@ -430,6 +427,6 @@ sudo firewall-cmd --reload | |||
| 430 | 427 | ||
| 431 | 428 | - [Configuration Overview](./index.md) | |
| 432 | 429 | - [Installation Guide](../installation.md) | |
| 433 | - | - [Routes Configuration](./routes.md) | |
| 430 | + | - [App Manifest](./apps.md) | |
| 434 | 431 | - [Standalone Services](./services.md) | |
| 435 | 432 | - [Traffic Plane Configuration](./traffic.md) | |
M docs/config/traffic.md +5 -5
| @@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive packets | |||
| 4 | 4 | ||
| 5 | 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 | 181 | - [CLI traffic status](../cli/traffic.md) | |
M docs/config/volumes.md +29 -137
| @@ -1,165 +1,57 @@ | |||
| 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 | 6 | ||
| 7 | - | ```toml | |
| 8 | - | [volumes] | |
| 9 | - | auto_create = true | |
| 10 | - | prefix = "gordon" | |
| 11 | - | preserve = true | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | ## Options | |
| 15 | - | ||
| 16 | - | | Option | Type | Default | Description | | |
| 17 | - | |--------|------|---------|-------------| | |
| 18 | - | | `auto_create` | bool | `true` | Automatically create volumes from Dockerfile VOLUME | | |
| 19 | - | | `prefix` | string | `"gordon"` | Prefix for volume names | | |
| 20 | - | | `preserve` | bool | `true` | Keep volumes when containers are removed | | |
| 21 | - | ||
| 22 | - | ## How It Works | |
| 23 | - | ||
| 24 | - | Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives: | |
| 25 | - | ||
| 26 | - | ```dockerfile | |
| 27 | - | FROM postgres:18 | |
| 28 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 29 | - | ``` | |
| 30 | - | ||
| 31 | - | When Gordon deploys this container: | |
| 32 | - | ||
| 33 | - | 1. Reads `VOLUME` directives from image metadata | |
| 34 | - | 2. Creates named volumes with `prefix-domain-path` naming | |
| 35 | - | 3. Mounts volumes to the container | |
| 36 | - | 4. Preserves data across container updates | |
| 37 | - | ||
| 38 | - | ## Volume Naming | |
| 39 | - | ||
| 40 | - | Volumes are named: `{prefix}-{domain}-{path}` | |
| 41 | - | ||
| 42 | - | | Domain | Volume Path | Volume Name | | |
| 43 | - | |--------|-------------|-------------| | |
| 44 | - | | `app.mydomain.com` | `/data` | `gordon-app-mydomain-com-data` | | |
| 45 | - | | `db.mydomain.com` | `/var/lib/postgresql/data` | `gordon-db-mydomain-com-var-lib-postgresql-data` | | |
| 46 | - | ||
| 47 | - | ## Persistence | |
| 48 | - | ||
| 49 | - | ### Default: Preserve Volumes | |
| 50 | - | ||
| 51 | - | ```toml | |
| 52 | - | [volumes] | |
| 53 | - | preserve = true | |
| 54 | - | ``` | |
| 55 | - | ||
| 56 | - | With `preserve = true`: | |
| 57 | - | - Volumes persist when containers are updated | |
| 58 | - | - Data survives container restarts | |
| 59 | - | - Volumes remain even if container is removed | |
| 60 | - | ||
| 61 | - | ### Remove with Container | |
| 62 | - | ||
| 63 | - | ```toml | |
| 64 | - | [volumes] | |
| 65 | - | preserve = false | |
| 66 | - | ``` | |
| 67 | - | ||
| 68 | - | With `preserve = false`: | |
| 69 | - | - Volumes are removed when containers are removed | |
| 70 | - | - Useful for stateless containers | |
| 71 | - | - Frees up disk space automatically | |
| 72 | - | ||
| 73 | - | ## Examples | |
| 74 | - | ||
| 75 | - | ### Database Container | |
| 76 | - | ||
| 77 | - | ```dockerfile | |
| 78 | - | # my-postgres.Dockerfile | |
| 79 | - | FROM postgres:18 | |
| 80 | - | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | - | ENV POSTGRES_DB=myapp | |
| 82 | - | ENV POSTGRES_USER=app | |
| 83 | - | ``` | |
| 84 | - | ||
| 85 | - | Gordon automatically: | |
| 86 | - | - Creates `gordon-db-mydomain-com-var-lib-postgresql-data` volume | |
| 87 | - | - Mounts it to `/var/lib/postgresql/data` | |
| 88 | - | - Preserves data across postgres container updates | |
| 89 | - | ||
| 90 | - | ### Application with Uploads | |
| 91 | - | ||
| 92 | - | ```dockerfile | |
| 93 | - | FROM node:18 | |
| 94 | - | WORKDIR /app | |
| 95 | - | VOLUME ["/app/uploads", "/app/data"] | |
| 96 | - | COPY . . | |
| 97 | - | CMD ["npm", "start"] | |
| 98 | - | ``` | |
| 99 | - | ||
| 100 | - | Creates two volumes: | |
| 101 | - | - `gordon-app-mydomain-com-app-uploads` | |
| 102 | - | - `gordon-app-mydomain-com-app-data` | |
| 103 | - | ||
| 104 | - | ### Custom Prefix | |
| 7 | + | Services declare persistent mounts in the app manifest: | |
| 105 | 8 | ||
| 106 | 9 | ```toml | |
| 107 | - | [volumes] | |
| 108 | - | prefix = "prod" | |
| 10 | + | [[service.volume]] | |
| 11 | + | name = "database-data" | |
| 12 | + | path = "/var/lib/postgresql/data" | |
| 109 | 13 | ``` | |
| 110 | 14 | ||
| 111 | - | Volume names become: | |
| 112 | - | - `prod-app-mydomain-com-data` | |
| 113 | - | - `prod-db-mydomain-com-var-lib-postgresql-data` | |
| 15 | + | A volume name is unique within its service. Gordon creates an incarnation-owned runtime volume, records the app, app UUID, service, and logical volume ownership, and reuses it across replacement and restart. App manifests do not support bind mounts or sharing one volume between services. | |
| 114 | 16 | ||
| 115 | - | ## Managing Volumes | |
| 17 | + | Every Dockerfile `VOLUME` path must have a matching `[[service.volume]]` declaration. Deployment fails closed when an image declares an unmanaged volume. | |
| 116 | 18 | ||
| 117 | - | ### List Volumes | |
| 19 | + | Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records. | |
| 118 | 20 | ||
| 119 | - | ```bash | |
| 120 | - | docker volume ls | grep gordon | |
| 121 | - | ``` | |
| 21 | + | ## Retention | |
| 122 | 22 | ||
| 123 | - | ### Inspect Volume | |
| 23 | + | Ordinary app operations retain data: | |
| 124 | 24 | ||
| 125 | - | ```bash | |
| 126 | - | docker volume inspect gordon-app-mydomain-com-data | |
| 127 | - | ``` | |
| 25 | + | - deploy and restart reuse the service's volumes; | |
| 26 | + | - removing a service retains its volumes; | |
| 27 | + | - `gordon apps remove` retains volumes under the removed app's internal UUID; | |
| 28 | + | - a new app that reuses the public name does not adopt retained volumes. | |
| 128 | 29 | ||
| 129 | - | ### Backup Volume | |
| 30 | + | Gordon does not automatically delete retained app volumes. Back up persistent data before any manual deletion, and use database-native backup and restore procedures for databases. | |
| 130 | 31 | ||
| 131 | - | ```bash | |
| 132 | - | docker run --rm \ | |
| 133 | - | -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \ | |
| 134 | - | -v $(pwd):/backup \ | |
| 135 | - | alpine tar -czf /backup/db-backup.tar.gz -C /data . | |
| 136 | - | ``` | |
| 32 | + | ## Pruning | |
| 137 | 33 | ||
| 138 | - | ### Restore Volume | |
| 34 | + | Use Gordon's ownership-aware command: | |
| 139 | 35 | ||
| 140 | 36 | ```bash | |
| 141 | - | docker run --rm \ | |
| 142 | - | -v gordon-db-mydomain-com-var-lib-postgresql-data:/data \ | |
| 143 | - | -v $(pwd):/backup \ | |
| 144 | - | alpine tar -xzf /backup/db-backup.tar.gz -C /data | |
| 37 | + | gordon volumes prune --dry-run | |
| 38 | + | gordon volumes prune | |
| 145 | 39 | ``` | |
| 146 | 40 | ||
| 147 | - | ## Volume Cleanup | |
| 41 | + | A volume is eligible only when all of these requirements hold: | |
| 148 | 42 | ||
| 149 | - | If you have orphaned volumes: | |
| 43 | + | 1. Gordon has a durable ownership record marking it `released`. | |
| 44 | + | 2. Runtime labels agree with the record's app, app incarnation UUID, and service. | |
| 45 | + | 3. No container mounts the volume. | |
| 150 | 46 | ||
| 151 | - | ```bash | |
| 152 | - | # List all gordon volumes | |
| 153 | - | docker volume ls -f name=gordon | |
| 47 | + | Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions. | |
| 154 | 48 | ||
| 155 | - | # Remove specific volume (warning: deletes data!) | |
| 156 | - | docker volume rm gordon-old-app-data | |
| 49 | + | > **Warning:** Do not use `docker volume prune`, `podman volume prune`, or equivalent runtime cleanup for Gordon data. Those commands bypass Gordon's ownership and retention checks and can delete an unmounted retained volume. | |
| 157 | 50 | ||
| 158 | - | # Prune unused volumes (be careful!) | |
| 159 | - | docker volume prune | |
| 160 | - | ``` | |
| 51 | + | See the [Volumes CLI reference](../cli/volumes.md) for flags and plan output. | |
| 161 | 52 | ||
| 162 | 53 | ## Related | |
| 163 | 54 | ||
| 164 | - | - [Attachments](./attachments.md) | |
| 55 | + | - [App Manifest](./apps.md) | |
| 56 | + | - [Volumes CLI](../cli/volumes.md) | |
| 165 | 57 | - [Configuration Overview](./index.md) | |
M docs/deployment/generic-ci.md +16 -16
| @@ -5,7 +5,7 @@ Deploy with Gordon from any CI/CD system. | |||
| 5 | 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 | 11 | ## Recommended: gordon push | |
| @@ -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 | ||
| @@ -42,7 +42,7 @@ chmod +x /usr/local/bin/gordon | |||
| 42 | 42 | export GORDON_TOKEN="$GORDON_TOKEN" | |
| 43 | 43 | gordon 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: | |
| @@ -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 push --build --remote "$GORDON_REMOTE" | |
| 93 | 93 | ''' | |
| 94 | 94 | } | |
| 95 | 95 | } | |
| @@ -120,8 +120,7 @@ jobs: | |||
| 120 | 120 | name: Deploy | |
| 121 | 121 | command: | | |
| 122 | 122 | gordon push --build \ | |
| 123 | - | --remote "$GORDON_REMOTE" \ | |
| 124 | - | --no-confirm | |
| 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 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,14 +175,13 @@ 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" | |
M docs/deployment/github-actions.md +10 -14
| @@ -4,8 +4,8 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme | |||
| 4 | 4 | ||
| 5 | 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 | 11 | ## Recommended: gordon push | |
| @@ -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 | ||
| @@ -72,8 +72,7 @@ jobs: | |||
| 72 | 72 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 73 | 73 | run: | | |
| 74 | 74 | gordon push --build \ | |
| 75 | - | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 76 | - | --no-confirm | |
| 75 | + | --remote ${{ secrets.GORDON_REMOTE }} | |
| 77 | 76 | ``` | |
| 78 | 77 | ||
| 79 | 78 | #### Continuous Deploy on Main | |
| @@ -105,8 +104,7 @@ jobs: | |||
| 105 | 104 | run: | | |
| 106 | 105 | gordon 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") | |
| @@ -176,8 +174,7 @@ jobs: | |||
| 176 | 174 | gordon 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: | |
| @@ -186,8 +183,7 @@ jobs: | |||
| 186 | 183 | gordon 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 | |
| @@ -204,7 +200,7 @@ Pass build arguments to the Docker build: | |||
| 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 | ||
M docs/deployment/gitlab-ci.md +9 -9
| @@ -4,8 +4,8 @@ Automated deployment with GitLab CI/CD using Gordon's push command. | |||
| 4 | 4 | ||
| 5 | 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 | ||
| @@ -48,7 +48,7 @@ deploy: | |||
| 48 | 48 | - gordon push --build | |
| 49 | 49 | --remote "$GORDON_REMOTE" | |
| 50 | 50 | --token "$GORDON_TOKEN" | |
| 51 | - | --no-confirm | |
| 51 | + | ||
| 52 | 52 | only: | |
| 53 | 53 | - tags | |
| 54 | 54 | ``` | |
| @@ -76,7 +76,7 @@ deploy: | |||
| 76 | 76 | - gordon 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 | ``` | |
| @@ -99,7 +99,7 @@ deploy: | |||
| 99 | 99 | - gordon 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 | ``` | |
| @@ -120,7 +120,7 @@ deploy: | |||
| 120 | 120 | - gordon push --build | |
| 121 | 121 | --remote "$GORDON_REMOTE" | |
| 122 | 122 | --token "$GORDON_TOKEN" | |
| 123 | - | --no-confirm | |
| 123 | + | ||
| 124 | 124 | when: manual | |
| 125 | 125 | ``` | |
| 126 | 126 | ||
| @@ -142,7 +142,7 @@ deploy: | |||
| 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 | ||
M docs/deployment/index.md +42 -68
| @@ -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 push + apps deploy | |
| 6 | 6 | ||
| 7 | - | `gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines. | |
| 7 | + | `gordon push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI. | |
| 8 | 8 | ||
| 9 | - | - Single command: build + push + deploy | |
| 10 | 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 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 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 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 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,24 @@ 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 | + | [[service]] | |
| 117 | + | name = "web" | |
| 118 | + | image = "gordon.example.com/myapp:latest" | |
| 133 | 119 | ``` | |
| 134 | 120 | ||
| 135 | 121 | ### Semantic Versioning | |
| 136 | 122 | ||
| 137 | - | Pin routes to specific versions and update config to roll forward: | |
| 123 | + | Pin services to specific versions and apply a new manifest to roll forward: | |
| 138 | 124 | ||
| 139 | 125 | ```bash | |
| 140 | 126 | docker tag myapp gordon.example.com/myapp:v2.1.0 | |
| 141 | 127 | docker push gordon.example.com/myapp:v2.1.0 | |
| 142 | 128 | ``` | |
| 143 | 129 | ||
| 144 | - | ```toml | |
| 145 | - | [routes] | |
| 146 | - | "app.example.com" = "myapp:v2.1.0" | |
| 147 | - | ``` | |
| 148 | - | ||
| 149 | - | To deploy a new version, update the tag in `gordon.toml` and push the new image. | |
| 130 | + | To deploy a new version, update the tag in the app file, apply, and deploy. | |
| 150 | 131 | ||
| 151 | 132 | ### Git SHA Tags | |
| 152 | 133 | ||
| @@ -158,20 +139,15 @@ docker tag myapp gordon.example.com/myapp:$VERSION | |||
| 158 | 139 | docker push gordon.example.com/myapp:$VERSION | |
| 159 | 140 | ``` | |
| 160 | 141 | ||
| 161 | - | ## Zero-Downtime Deployment | |
| 162 | - | ||
| 163 | - | Gordon performs zero-downtime deployments by default: | |
| 142 | + | ## Updates | |
| 164 | 143 | ||
| 165 | - | 1. **New container starts** while the old one is still running | |
| 166 | - | 2. **Health check** waits for the new container to become ready | |
| 167 | - | 3. **Traffic switches** to the new container | |
| 168 | - | 4. **Old container stops** after traffic moves | |
| 144 | + | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged. | |
| 169 | 145 | ||
| 170 | 146 | ``` | |
| 171 | 147 | Timeline ─────────────────────────────────────────> | |
| 172 | 148 | ||
| 173 | 149 | Old Container: [═══════════════════] | |
| 174 | - | ↓ stop | |
| 150 | + | ↓ retire | |
| 175 | 151 | New Container: [═════════════════════════> | |
| 176 | 152 | ↑ start ↑ traffic routed | |
| 177 | 153 | ``` | |
| @@ -181,7 +157,5 @@ New Container: [══════════════════ | |||
| 181 | 157 | - [GitHub Actions](./github-actions.md) | |
| 182 | 158 | - [GitLab CI](./gitlab-ci.md) | |
| 183 | 159 | - [Generic CI](./generic-ci.md) | |
| 184 | - | - [Rollback](./rollback.md) | |
| 185 | - | - [Routes Configuration](../config/routes.md) | |
| 160 | + | - [Apps CLI](../cli/apps.md) | |
| 186 | 161 | - [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 v2.50: rolling back means applying a manifest that references the previous tag (or re-pushing it) and deploying again. | |
| 4 | 4 | ||
| 5 | - | ## Recommended: Use `gordon pin` | |
| 5 | + | ## Roll Forward to a Previous Tag | |
| 6 | 6 | ||
| 7 | - | `gordon pin` is the built-in rollback and roll-forward workflow for route images: | |
| 7 | + | The image tags are still in the registry. Point the app file at the last good tag, apply, deploy: | |
| 8 | 8 | ||
| 9 | 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 | + | For HTTP services without volumes, the previous (broken) container keeps serving until the replacement passes readiness, so the recovery itself is zero-downtime. | |
| 16 | 16 | ||
| 17 | - | ## Rollback 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 -32
| @@ -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]`, `[[services]]`-as-apps, `[service_routes]`, `[auto_route]`, and `[previews]` keys were removed in v2.50 — Gordon refuses to start when any of them is present. | |
| 85 | + | ||
| 87 | 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,66 @@ 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 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 | + | [[service]] | |
| 160 | + | name = "web" | |
| 161 | + | image = "gordon.mydomain.com/myapp:latest" | |
| 162 | + | ||
| 163 | + | [[service.http]] | |
| 164 | + | host = "app.mydomain.com" | |
| 165 | + | port = 3000 | |
| 166 | + | ``` | |
| 165 | 167 | ||
| 166 | - | If the route already exists and you only need to push a new image version, use: | |
| 168 | + | Apply the manifest, then deploy: | |
| 167 | 169 | ||
| 168 | 170 | ```bash | |
| 169 | - | gordon push myapp --build --no-confirm | |
| 171 | + | gordon apps apply --file blog.toml --remote prod | |
| 172 | + | gordon apps deploy blog --remote prod | |
| 170 | 173 | ``` | |
| 171 | 174 | ||
| 172 | - | `gordon push` still requires the route to already exist so it can resolve the | |
| 173 | - | deploy target. | |
| 175 | + | What these commands do: | |
| 176 | + | ||
| 177 | + | - `gordon push` builds, uploads, and stores the image. It never deploys. | |
| 178 | + | - `gordon apps apply` validates the manifest and persists it as desired state. | |
| 179 | + | - `gordon apps deploy` activates the accepted revision: pulls the image, starts the container, waits for readiness, switches traffic, retires the old container. | |
| 180 | + | ||
| 181 | + | If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: | |
| 182 | + | ||
| 183 | + | ```bash | |
| 184 | + | gordon apps secrets set blog --service web APP_ENV=production --remote prod | |
| 185 | + | ``` | |
| 174 | 186 | ||
| 175 | 187 | Your app is now live at `https://app.mydomain.com`! | |
| 176 | 188 | ||
| 177 | 189 | ## 9. Update Your App | |
| 178 | 190 | ||
| 179 | - | Push a new image to deploy with zero downtime: | |
| 191 | + | Push a new image, update the manifest tag if needed, apply, deploy: | |
| 180 | 192 | ||
| 181 | 193 | ```bash | |
| 182 | - | # Make changes, then build + push + deploy | |
| 183 | - | gordon push myapp --build --no-confirm | |
| 194 | + | # Make changes, then build + push | |
| 195 | + | gordon push myapp --build --remote prod | |
| 196 | + | ||
| 197 | + | # Deploy the new tag (re-resolves mutable tags) | |
| 198 | + | gordon apps deploy blog --remote prod | |
| 184 | 199 | ``` | |
| 185 | 200 | ||
| 186 | - | Gordon automatically: | |
| 187 | - | 1. Starts the new container | |
| 188 | - | 2. Waits for it to be ready | |
| 189 | - | 3. Routes traffic to the new container | |
| 190 | - | 4. Stops the old container | |
| 201 | + | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. TCP/UDP and volume-owning services replace with interruption. | |
| 191 | 202 | ||
| 192 | 203 | ## Next Steps | |
| 193 | 204 | ||
| 194 | 205 | - [Installation Guide](./installation.md) - Production setup with firewall and rootless containers | |
| 195 | - | - [Configuration Reference](./config/index.md) - All configuration options | |
| 206 | + | - [Configuration Reference](./config/index.md) - All installation configuration options | |
| 207 | + | - [Apps CLI](./cli/apps.md) - Apply, deploy, and lifecycle commands | |
| 196 | 208 | - [Authentication](./config/auth.md) - Secure your registry | |
| 197 | - | - [Environment Variables](./config/env.md) - Configure per-app settings | |
| 209 | + | - [App secrets](./cli/apps.md#gordon-apps-secrets) - Service-scoped secret values in pass | |
| 198 | 210 | ||
| 199 | 211 | ## Related | |
| 200 | 212 | ||
M docs/index.md +10 -11
| @@ -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 | + | - **HTTP Zero-Downtime Updates** - Old containers serve until replacements pass readiness | |
| 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 v2.50](./migrate-to-v2.50.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 | ||
M docs/installation.md +40 -10
| @@ -29,7 +29,25 @@ Detailed installation guide for production environments. | |||
| 29 | 29 | ) | |
| 30 | 30 | ``` | |
| 31 | 31 | ||
| 32 | - | Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`. | |
| 32 | + | Download and inspect the installer before executing it. The installer verifies the release archive checksum, detects Linux/macOS and amd64/arm64, and installs Gordon to `~/.local/bin` without `sudo`. If that directory is absent from `PATH`, an interactive install can offer to add an idempotent marker block to Fish (`~/.config/fish/config.fish`), Bash (`~/.bashrc`), or Zsh (`~/.zshrc`). Restart the shell after accepting, or follow the printed command to update the current shell. | |
| 33 | + | ||
| 34 | + | For unattended installs, control PATH configuration explicitly: | |
| 35 | + | ||
| 36 | + | ```bash | |
| 37 | + | # Update supported shell configuration without prompting | |
| 38 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_UPDATE_PATH=1 sh | |
| 39 | + | ||
| 40 | + | # Never modify shell configuration | |
| 41 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_UPDATE_PATH=0 sh | |
| 42 | + | ||
| 43 | + | # Use another user-local directory | |
| 44 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_INSTALL_DIR="$HOME/bin" GORDON_UPDATE_PATH=1 sh | |
| 45 | + | ||
| 46 | + | # Explicit global installation (may request sudo) | |
| 47 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_INSTALL_DIR=/usr/local/bin GORDON_UPDATE_PATH=0 sh | |
| 48 | + | ``` | |
| 49 | + | ||
| 50 | + | `GORDON_INSTALL_DIR` accepts arbitrary safe absolute destinations, including `"$HOME/bin"`, `"$HOME/.local/bin"`, and `/usr/local/bin`. PATH detection and shell configuration use that effective directory. Relative paths, PATH separators, and control characters are rejected. `GORDON_UPDATE_PATH` accepts only `0` or `1`. Do not run the default installer through `sudo`: it refuses to infer a user home and install silently under `/root`. | |
| 33 | 51 | ||
| 34 | 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://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 sh | |
| 345 | + | ||
| 346 | + | # Install the latest pre-release | |
| 347 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 sh | |
| 318 | 348 | ||
| 319 | - | # Install the latest pre-release instead of the latest stable release | |
| 320 | - | curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash | |
| 349 | + | # Build the current next branch commit from source | |
| 350 | + | curl -fsSL https://gordon.bnema.dev/install | GORDON_CHANNEL=next sh | |
| 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 | ||
A docs/migrate-to-v2.50.md +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | # Migrate to Gordon v2.50 | |
| 2 | + | ||
| 3 | + | Gordon v2.50 replaces domain-based route workloads with declarative apps. The upgrade is intentionally explicit: Gordon does not infer app ownership from domains, does not adopt existing volumes, and does not copy domain secrets into app secrets automatically. | |
| 4 | + | ||
| 5 | + | Use this guide before starting the v2.50 daemon with production traffic. | |
| 6 | + | ||
| 7 | + | ## Before the upgrade | |
| 8 | + | ||
| 9 | + | 1. Stop deployments and automatic image pruning. | |
| 10 | + | 2. Back up Gordon's configuration, state, registry, databases, volumes, and secret store using your existing procedures. | |
| 11 | + | 3. Record every current domain, image tag or digest, attachment, network relationship, volume, and secret key. | |
| 12 | + | 4. Keep the previous signed Gordon binary and its matching configuration and state backup available for rollback. | |
| 13 | + | ||
| 14 | + | Do not delete old containers, volumes, pass entries, or registry tags during the migration. Gordon's ownership-aware reconciliation and prune paths preserve resources whose ownership cannot be proven. Runtime-native cleanup commands and manual deletion do not provide that guarantee. | |
| 15 | + | ||
| 16 | + | ## 1. Update the installation configuration | |
| 17 | + | ||
| 18 | + | Remove workload declarations that v2.50 no longer accepts from `gordon.toml`: | |
| 19 | + | ||
| 20 | + | - `[routes]`; | |
| 21 | + | - `[attachments]`; | |
| 22 | + | - `[network_groups]`; | |
| 23 | + | - app-like `[[services]]` and `[service_routes]`; | |
| 24 | + | - `[auto_route]` and `[auto_route_allowed_domains]`; | |
| 25 | + | - `[previews]`. | |
| 26 | + | ||
| 27 | + | Keep installation-level settings such as entrypoints, TLS, limits, registry policy, external routes, backup destinations, logging, and authentication. | |
| 28 | + | ||
| 29 | + | Review every app image registry before applying manifests. v2.50 allows Docker Hub (`docker.io`, including its canonical pull host `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's configured registry by default. Add each other registry, including private registries, as an exact hostname and optional non-default port: | |
| 30 | + | ||
| 31 | + | ```toml | |
| 32 | + | [images] | |
| 33 | + | allowed_registries = ["registry.internal:5000"] | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | Hostname matching is case-insensitive, ignores one trailing dot, and treats port `443` as the default. The allowlist is checked during manifest apply, deployment preflight, and immediately before pull. It controls registry names only: it does not prove that DNS resolves to a public IP or enforce runtime egress. Use firewall or runtime network policy when destination-level restrictions are required. | |
| 37 | + | ||
| 38 | + | Create one app manifest per workload. See [App Manifest](./config/apps.md). | |
| 39 | + | ||
| 40 | + | ```toml | |
| 41 | + | name = "example-app" | |
| 42 | + | ||
| 43 | + | [[service]] | |
| 44 | + | name = "web" | |
| 45 | + | image = "registry.example.com/example-app:v2.50.0" | |
| 46 | + | ||
| 47 | + | [service.secrets] | |
| 48 | + | DATABASE_URL = "database-url" | |
| 49 | + | ||
| 50 | + | [[service.http]] | |
| 51 | + | host = "app.example.com" | |
| 52 | + | port = 8080 | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | The manifest contains secret **names**, never secret values. | |
| 56 | + | ||
| 57 | + | ## 2. Migrate domain secrets | |
| 58 | + | ||
| 59 | + | Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[service.secrets]`, then apply the manifest before setting values: | |
| 60 | + | ||
| 61 | + | ```bash | |
| 62 | + | gordon apps apply --file ./example-app.toml --remote production | |
| 63 | + | printf 'DATABASE_URL=%s\n' "$(pass show gordon/env/app_example_com/DATABASE_URL)" \ | |
| 64 | + | | gordon apps secrets set example-app --service web --stdin --remote production | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | Pass values through standard input. Do not put them in manifests, command arguments, logs, shell history, or temporary files. Repeat explicitly when several services need the same value. | |
| 68 | + | ||
| 69 | + | Deploy and restart the app to verify that it uses the new app-scoped entries. Keep the old `gordon/env/...` entries throughout the rollback window; Gordon never uses them as a fallback. Refer to the `pass` documentation for password-store and GPG backup or restore procedures. | |
| 70 | + | ||
| 71 | + | ## 3. Transfer existing volume data | |
| 72 | + | ||
| 73 | + | Gordon's reconciliation and prune paths leave existing unowned volumes untouched, but Gordon does not adopt them. After applying the manifest, let Gordon create the new app-owned volumes, then transfer the data with your container runtime's tools while the workload is stopped. Do not run runtime-wide volume prune, edit `state.db`, rename volumes, or alter ownership labels. | |
| 74 | + | ||
| 75 | + | Use a database-native backup and restore for databases instead of copying live database files. Keep the old volumes unchanged until the new app has passed data, deploy, restart, and rollback-readiness checks. Refer to the Docker or Podman documentation for runtime-specific copy and inspection commands. | |
| 76 | + | ||
| 77 | + | ## 4. Apply and deploy apps | |
| 78 | + | ||
| 79 | + | For every manifest: | |
| 80 | + | ||
| 81 | + | ```bash | |
| 82 | + | gordon apps apply --file ./example-app.toml --remote production | |
| 83 | + | gordon apps diff example-app --remote production | |
| 84 | + | gordon apps deploy example-app --remote production | |
| 85 | + | gordon apps status example-app --json --remote production | |
| 86 | + | ``` | |
| 87 | + | ||
| 88 | + | Push only uploads OCI content in v2.50; it does not deploy. Apply and deploy explicitly. | |
| 89 | + | ||
| 90 | + | ## 5. Replace tokens and automation | |
| 91 | + | ||
| 92 | + | Replace removed route scopes with app scopes: | |
| 93 | + | ||
| 94 | + | - `admin:apps:read` for list, show, diff, and status; | |
| 95 | + | - `admin:apps:write` for apply, deploy, lifecycle, and app secrets. | |
| 96 | + | ||
| 97 | + | Generate replacement CI and operator tokens with the minimum required scopes. Update automation that expected push-to-deploy, route mutation commands, previews, attachments, bootstrap, autoroute, pin, or rollback commands. | |
| 98 | + | ||
| 99 | + | To roll back an application release in v2.50, apply a manifest containing the previous image tag and deploy it. | |
| 100 | + | ||
| 101 | + | ## 6. Validate before opening traffic | |
| 102 | + | ||
| 103 | + | Verify all of the following: | |
| 104 | + | ||
| 105 | + | - each app is converged on the expected pinned digest; | |
| 106 | + | - HTTP, TCP, and UDP entrypoints work as applicable; | |
| 107 | + | - app secrets survive a restart and no value appears in output or logs; | |
| 108 | + | - named volumes contain the expected data after deploy and restart; | |
| 109 | + | - stopped intent survives a daemon restart; | |
| 110 | + | - retained and unknown resources remain untouched; | |
| 111 | + | - `gordon images prune --dry-run` reports protected app content correctly before any real prune; | |
| 112 | + | - the previous binary and matching data backup remain available. | |
| 113 | + | ||
| 114 | + | Run destructive prune commands only after their dry-run candidate list matches the expected ownership and retention policy. | |
| 115 | + | ||
| 116 | + | ## Security guarantees to rely on after the upgrade | |
| 117 | + | ||
| 118 | + | - **Containment first.** Keep `auth.enabled = true` and automatic registry prune disabled (`images.prune.enabled = false`) until the upgraded build is running and validated. A disabled-auth registry is local-only: public ingress cannot reach it through the proxy. | |
| 119 | + | - **Network isolation.** Every app container joins an app-owned private network; shared memberships come only from explicit manifest declarations, and container memory/CPU/PID limits are applied on create and recovery. | |
| 120 | + | - **Registry content is repository-scoped.** Pulls require the repository to have completed the upload of a blob, and manifests may only reference content that repository owns. Content uploaded by an older build has no durable ownership marker, so a repository whose blobs predate this build must re-push its images before they can be pulled again. | |
| 121 | + | - **Runtime image prune needs released ownership.** An app's pinned images are recorded as attached while deployed and released when the app is removed. Dangling images with no durable ownership record are never deleted, and image labels alone never authorize deletion. | |
| 122 | + | - **Durable roots keep their closure.** A digest pinned by active/desired state, an apply intent, an operation, or ownership retains its manifest, config, and layers even if the tag moves away. | |
| 123 | + | - **Plaintext TLS policy.** Hosts declared `tls = always` never receive plaintext traffic: they redirect when an HTTPS endpoint exists and are refused with `421` when none does. | |
| 124 | + | - **Failure output is log-free.** Mutation responses and operation lookup for `admin:apps:read` carry stable errors only; application diagnostics require `admin:logs:read` and are redacted before storage. | |
| 125 | + | ||
| 126 | + | ## Rollback | |
| 127 | + | ||
| 128 | + | A binary downgrade against v2.50 app state is unsupported. To roll back: | |
| 129 | + | ||
| 130 | + | 1. stop the v2.50 daemon; | |
| 131 | + | 2. restore the previous signed binary; | |
| 132 | + | 3. restore its matching configuration and state backup as one set; | |
| 133 | + | 4. restore secret-store entries if any were removed; | |
| 134 | + | 5. restart with automatic pruning disabled; | |
| 135 | + | 6. verify workloads and data before reopening traffic. | |
| 136 | + | ||
| 137 | + | Do not attempt rollback by adopting unknown containers or volumes into v2.50 state. | |
| 138 | + | ||
| 139 | + | ## Related | |
| 140 | + | ||
| 141 | + | - [Upgrading Gordon](./upgrading.md) | |
| 142 | + | - [App Manifest](./config/apps.md) | |
| 143 | + | - [Apps CLI](./cli/apps.md) | |
| 144 | + | - [Secrets](./config/secrets.md) | |
| 145 | + | - [Authentication](./config/auth.md) | |
M docs/reference/docker-labels.md +22 -150
| @@ -4,122 +4,57 @@ Labels used by Gordon for container and image metadata. | |||
| 4 | 4 | ||
| 5 | 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 v2.50 may carry these labels. They are read-only provenance hints for prune guards — Gordon never infers app state from them: | |
| 31 | 22 | ||
| 32 | 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-v2.50 domain this container served | | |
| 26 | + | | `gordon.image` | Image:tag | Original image from configuration | | |
| 27 | + | | `gordon.route` | Domain name | Pre-v2.50 route this container handled | | |
| 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 `[service.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 v2.50: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[service.readiness]`, `[[service.http]]`), not from image labels. | |
| 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) | |
M docs/reference/troubleshooting.md +16 -30
| @@ -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,9 +157,9 @@ 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: | |
| @@ -234,18 +230,14 @@ gordon reload | |||
| 234 | 230 | ||
| 235 | 231 | **Solutions:** | |
| 236 | 232 | ||
| 237 | - | 1. Check attachments are configured: | |
| 238 | - | ```toml | |
| 239 | - | [attachments] | |
| 240 | - | "app.mydomain.com" = ["postgres:latest"] | |
| 241 | - | ``` | |
| 233 | + | 1. Check that both services declare the same shared network in the app manifest. | |
| 242 | 234 | ||
| 243 | - | 2. Check containers are in same network: | |
| 235 | + | 2. Check containers are attached to that network: | |
| 244 | 236 | ```bash | |
| 245 | - | docker network inspect gordon-app-mydomain-com | |
| 237 | + | docker network inspect NETWORK_NAME | |
| 246 | 238 | ``` | |
| 247 | 239 | ||
| 248 | - | 3. Use correct hostname (image name before colon): | |
| 240 | + | 3. Use the service name as the internal hostname: | |
| 249 | 241 | ```javascript | |
| 250 | 242 | // Correct | |
| 251 | 243 | connect("postgresql://postgres:5432/mydb") | |
| @@ -369,15 +361,9 @@ enabled = true | |||
| 369 | 361 | path = "~/.gordon/logs/gordon.log" | |
| 370 | 362 | ``` | |
| 371 | 363 | ||
| 372 | - | ### Container logs missing | |
| 373 | - | ||
| 374 | - | **Cause:** Container log collection disabled. | |
| 364 | + | ### Workload logs unavailable | |
| 375 | 365 | ||
| 376 | - | **Solution:** | |
| 377 | - | ```toml | |
| 378 | - | [logging.container_logs] | |
| 379 | - | enabled = true # default: true | |
| 380 | - | ``` | |
| 366 | + | `gordon apps logs APP --service SERVICE` reads directly from the container runtime. Confirm that the app has an active deployment, use the exact service name, and check the runtime's logging driver and retention settings. Gordon does not write workload logs to `logging.container_logs` files. | |
| 381 | 367 | ||
| 382 | 368 | ## Diagnostic Commands | |
| 383 | 369 | ||
| @@ -398,11 +384,11 @@ docker network ls | grep gordon | |||
| 398 | 384 | # List volumes | |
| 399 | 385 | docker volume ls | grep gordon | |
| 400 | 386 | ||
| 401 | - | # Check container logs | |
| 402 | - | docker logs gordon-app-mydomain-com | |
| 387 | + | # Check workload logs through Gordon | |
| 388 | + | gordon apps logs blog --service web | |
| 403 | 389 | ||
| 404 | - | # Inspect container | |
| 405 | - | docker inspect gordon-app-mydomain-com | |
| 390 | + | # Inspect runtime containers when diagnosing locally | |
| 391 | + | docker ps -f "label=gordon.app=blog" | |
| 406 | 392 | ||
| 407 | 393 | # Check connectivity | |
| 408 | 394 | curl -v http://localhost:5000/v2/ | |
M docs/upgrading.md +26 -4
| @@ -2,6 +2,28 @@ | |||
| 2 | 2 | ||
| 3 | 3 | This guide covers breaking changes and migration steps between major versions. | |
| 4 | 4 | ||
| 5 | + | ## v2.50.0: Declarative Apps (breaking) | |
| 6 | + | ||
| 7 | + | v2.50 replaces route-container management with declarative apps. One standalone TOML file defines one app; `gordon apps apply` persists desired state and `gordon apps deploy` activates it. Push transfers OCI content only and never deploys. | |
| 8 | + | ||
| 9 | + | Follow [Migrate to Gordon v2.50](./migrate-to-v2.50.md) for the complete cutover procedure, including explicit migration from domain-scoped secrets to app- and service-scoped secrets. | |
| 10 | + | ||
| 11 | + | ### Removed | |
| 12 | + | ||
| 13 | + | - `[routes]`, `[attachments]`, `[network_groups]`, `[[services]]`-as-apps, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), `network_services`-as-apps, `[previews]` keys in `gordon.toml`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. | |
| 14 | + | - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`. | |
| 15 | + | - Scopes `admin:routes:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`. | |
| 16 | + | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | |
| 17 | + | ||
| 18 | + | ### Manual migration | |
| 19 | + | ||
| 20 | + | 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted. | |
| 21 | + | 2. Delete the removed keys from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay). | |
| 22 | + | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[service.http]]` hosts, `[service.secrets]` names, volumes, `[[network.shared]]`, backup declarations. | |
| 23 | + | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v2.50 secrets migration procedure](./migrate-to-v2.50.md#2-migrate-domain-secrets). | |
| 24 | + | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | |
| 25 | + | 6. Staging is an ordinary app in another file. A binary downgrade against the new app-state format is unsupported: restore the old installation/config/state and backups through an operator-approved procedure. | |
| 26 | + | ||
| 5 | 27 | ## Route-Domain Validation | |
| 6 | 28 | ||
| 7 | 29 | Route keys must be plain hostnames. Use inline tables like `"app.example.com" = { image = "myapp:latest" }`. Gordon still reads legacy `http://...` route entries for backward compatibility and rewrites them on the next save. Update `[routes]`, CLI commands, and automation that reference the old values. | |
| @@ -114,7 +136,7 @@ registry_domain = "gordon.example.com" | |||
| 114 | 136 | gordon_domain = "gordon.example.com" | |
| 115 | 137 | ``` | |
| 116 | 138 | ||
| 117 | - | If you do not migrate, `gordon status --remote ...` and `gordon routes list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. | |
| 139 | + | If you do not migrate, `gordon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. | |
| 118 | 140 | ||
| 119 | 141 | ### Staged Registry Host Rename | |
| 120 | 142 | ||
| @@ -214,7 +236,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based | |||
| 214 | 236 | - `gordon auth show-token` prints the stored token for a remote | |
| 215 | 237 | - `gordon auth logout` removes the stored token locally | |
| 216 | 238 | - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls | |
| 217 | - | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |
| 239 | + | - Admin scopes (`admin:*:*`, `admin:apps:read`, `admin:apps:write`, etc.) allow fine-grained access control for remote CLI operations | |
| 218 | 240 | ||
| 219 | 241 | **Migration steps:** | |
| 220 | 242 | ||
| @@ -294,8 +316,8 @@ gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expi | |||
| 294 | 316 | # Read-only monitoring | |
| 295 | 317 | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |
| 296 | 318 | ||
| 297 | - | # CI deploy with route read + config write | |
| 298 | - | gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0 | |
| 319 | + | # CI deploy with app read + write | |
| 320 | + | gordon auth token generate --subject ci --scopes "push,pull,admin:apps:read,admin:apps:write" --expiry 0 | |
| 299 | 321 | ``` | |
| 300 | 322 | ||
| 301 | 323 | See [Token Scopes](./config/auth.md#token-scopes) for the full list. | |
M go.mod +2 -1
| @@ -32,6 +32,7 @@ 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 | |
| @@ -43,7 +44,6 @@ require ( | |||
| 43 | 44 | go.opentelemetry.io/otel/sdk/metric v1.46.0 | |
| 44 | 45 | go.opentelemetry.io/otel/trace v1.46.0 | |
| 45 | 46 | go.uber.org/mock v0.6.0 // direct | |
| 46 | - | golang.org/x/mod v0.40.0 | |
| 47 | 47 | golang.org/x/sync v0.22.0 | |
| 48 | 48 | golang.org/x/sys v0.47.0 | |
| 49 | 49 | golang.org/x/time v0.15.0 | |
| @@ -113,6 +113,7 @@ require ( | |||
| 113 | 113 | go.opentelemetry.io/proto/otlp v1.11.0 // indirect | |
| 114 | 114 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 115 | 115 | golang.org/x/crypto v0.55.0 // indirect | |
| 116 | + | golang.org/x/mod v0.40.0 // indirect | |
| 116 | 117 | golang.org/x/net v0.58.0 // indirect | |
| 117 | 118 | golang.org/x/text v0.41.0 // indirect | |
| 118 | 119 | golang.org/x/tools v0.49.0 // indirect | |
M go.sum +2 -0 generated file: collapsed
M gordon.toml.example +28 -20
| @@ -70,6 +70,9 @@ drain_delay = "2s" | |||
| 70 | 70 | security_profile = "compat" | |
| 71 | 71 | ||
| 72 | 72 | [network_isolation] | |
| 73 | + | # Installation network policy for Gordon-managed networks (prefix filter | |
| 74 | + | # for `gordon networks list`). Per-app isolation is removed: apps declare | |
| 75 | + | # `[[network.shared]]` membership in their app file instead. | |
| 73 | 76 | enabled = true | |
| 74 | 77 | network_prefix = "gordon" | |
| 75 | 78 | # Set true to create Docker internal networks without direct external egress. | |
| @@ -81,10 +84,10 @@ prefix = "gordon" | |||
| 81 | 84 | preserve = true | |
| 82 | 85 | ||
| 83 | 86 | [images] | |
| 84 | - | # Explicit external registries must be listed here. Gordon's own registry is | |
| 85 | - | # always allowed; localhost/private/link-local registries are always rejected. | |
| 87 | + | # Docker Hub, ghcr.io, quay.io, and Gordon's registry are always allowed. | |
| 88 | + | # Add other registries as exact hostname+port entries; this does not configure credentials. | |
| 86 | 89 | allowed_registries = [] | |
| 87 | - | # Require @sha256:<digest> for allowlisted external registries. | |
| 90 | + | # Require @sha256:<64 hex chars> for images from every registry, including Gordon. | |
| 88 | 91 | require_digest = false | |
| 89 | 92 | ||
| 90 | 93 | [images.prune] | |
| @@ -92,9 +95,6 @@ enabled = false | |||
| 92 | 95 | schedule = "daily" | |
| 93 | 96 | keep_last = 3 | |
| 94 | 97 | ||
| 95 | - | [auto_route] | |
| 96 | - | enabled = false | |
| 97 | - | ||
| 98 | 98 | [logging] | |
| 99 | 99 | level = "info" | |
| 100 | 100 | format = "console" | |
| @@ -106,13 +106,6 @@ enabled = false | |||
| 106 | 106 | # max_backups = 3 | |
| 107 | 107 | # max_age = 28 | |
| 108 | 108 | ||
| 109 | - | [logging.container_logs] | |
| 110 | - | enabled = true | |
| 111 | - | # dir = "~/.gordon/logs/containers" | |
| 112 | - | # max_size = 100 | |
| 113 | - | # max_backups = 3 | |
| 114 | - | # max_age = 28 | |
| 115 | - | ||
| 116 | 109 | [logging.access_log] | |
| 117 | 110 | enabled = false | |
| 118 | 111 | format = "json" | |
| @@ -173,9 +166,27 @@ keep = 14 | |||
| 173 | 166 | ||
| 174 | 167 | [external_routes] | |
| 175 | 168 | # "status.example.com" = "127.0.0.1:9000" | |
| 169 | + | # | |
| 170 | + | # NOTE: the pre-v2.50 application keys below were removed by the | |
| 171 | + | # declarative-apps cutover. Gordon fails boot/reload closed when any of | |
| 172 | + | # them is present (see validateRetiredAppConfig in internal/app/run.go). | |
| 173 | + | # Declare application workloads in standalone <app>.toml files instead: | |
| 174 | + | # one file per app, applied with `gordon apps apply --file <app>.toml` | |
| 175 | + | # and activated with `gordon apps deploy <app>`. | |
| 176 | + | # | |
| 177 | + | # REMOVED: [routes] -> [[service.http]] in an app file | |
| 178 | + | # REMOVED: [attachments] -> [[service]] + volumes in an app file | |
| 179 | + | # REMOVED: [network_groups] -> [[network.shared]] in an app file | |
| 180 | + | # REMOVED: [[services]]-as-app -> one [[service]] per app file | |
| 181 | + | # REMOVED: [service_routes] -> [[service.http]] in an app file | |
| 182 | + | # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file | |
| 183 | + | # REMOVED: [previews] -> staging is an ordinary app file | |
| 176 | 184 | ||
| 177 | 185 | # Gordon-managed standalone L4 services. Use service:<name>:<port> from | |
| 178 | - | # traffic routers to expose published loopback ports. | |
| 186 | + | # traffic routers to expose published loopback ports. Standalone services | |
| 187 | + | # are installation-level: they live in gordon.toml, not in app files. | |
| 188 | + | # (App services are declared with [[service]] in <app>.toml files and | |
| 189 | + | # managed with `gordon apps`; see docs/cli/apps.md.) | |
| 179 | 190 | # [[services]] | |
| 180 | 191 | # name = "rust" | |
| 181 | 192 | # image = "registry.example.com:5000/rust:latest" | |
| @@ -213,9 +224,6 @@ keep = 14 | |||
| 213 | 224 | # name = "rust-rcon" | |
| 214 | 225 | # entrypoint = "rcon" | |
| 215 | 226 | # service = "service:rust:rcon" | |
| 216 | - | ||
| 217 | - | [network_groups] | |
| 218 | - | # "backend" = ["app.example.com", "api.example.com"] | |
| 219 | - | ||
| 220 | - | [attachments] | |
| 221 | - | # "app.example.com" = ["postgres:18"] | |
| 227 | + | # | |
| 228 | + | # REMOVED application sections (see note under [external_routes] above): | |
| 229 | + | # [routes], [network_groups], [attachments] were deleted with the cutover. | |
M install.sh +301 -18
| @@ -2,12 +2,55 @@ | |||
| 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://gordon.bnema.dev/install | sh | |
| 6 | + | # Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 sh | |
| 7 | + | # Usage with next source build: curl -fsSL https://gordon.bnema.dev/install | GORDON_CHANNEL=next sh | |
| 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,257 @@ 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 | + | if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \ | |
| 315 | + | go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \ | |
| 316 | + | -o "$BINARY" .); then | |
| 317 | + | echo "Error: Failed to build Gordon from source" | |
| 318 | + | exit 1 | |
| 319 | + | fi | |
| 320 | + | if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then | |
| 321 | + | echo "Error: Build did not produce a regular Gordon binary" | |
| 322 | + | exit 1 | |
| 323 | + | fi | |
| 324 | + | ||
| 325 | + | echo "Installing to ${INSTALL_DIR}..." | |
| 326 | + | install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon" | |
| 327 | + | echo "Installed unverified next build ${COMMIT}." | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | case "$CHANNEL" in | |
| 331 | + | next) | |
| 332 | + | install_next | |
| 333 | + | post_install | |
| 334 | + | exit 0 | |
| 335 | + | ;; | |
| 336 | + | stable|'') ;; | |
| 337 | + | *) echo "Error: Unsupported GORDON_CHANNEL: $CHANNEL"; exit 1 ;; | |
| 338 | + | esac | |
| 339 | + | ||
| 46 | 340 | # Construct download URL | |
| 47 | 341 | TARBALL="gordon_${OS}_${ARCH}.tar.gz" | |
| 48 | 342 | ||
| @@ -101,6 +395,8 @@ fi | |||
| 101 | 395 | ||
| 102 | 396 | # Verify checksum | |
| 103 | 397 | echo "Verifying checksum..." | |
| 398 | + | # The sed expression is intentionally literal; shell expansion would corrupt it. | |
| 399 | + | # shellcheck disable=SC2016 | |
| 104 | 400 | TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g') | |
| 105 | 401 | CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true) | |
| 106 | 402 | CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ') | |
| @@ -147,19 +443,6 @@ if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then | |||
| 147 | 443 | fi | |
| 148 | 444 | ||
| 149 | 445 | echo "Installing to ${INSTALL_DIR}..." | |
| 150 | - | if [ -w "$INSTALL_DIR" ]; then | |
| 151 | - | install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 152 | - | else | |
| 153 | - | echo "sudo required to install to ${INSTALL_DIR}" | |
| 154 | - | sudo install -m 0755 "$BINARY" "$INSTALL_DIR/gordon" | |
| 155 | - | fi | |
| 446 | + | install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon" | |
| 156 | 447 | ||
| 157 | - | # Verify installation | |
| 158 | - | if command -v gordon >/dev/null 2>&1; then | |
| 159 | - | echo "" | |
| 160 | - | echo "Gordon installed successfully!" | |
| 161 | - | gordon version | |
| 162 | - | else | |
| 163 | - | echo "" | |
| 164 | - | echo "Installation complete. You may need to add ${INSTALL_DIR} to your PATH." | |
| 165 | - | fi | |
| 448 | + | post_install | |
A internal/adapters/dto/admin_apps.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // App admin DTOs use stable wire-facing field names. | |
| 6 | + | // No secret values exist in any shape here: secret references carry | |
| 7 | + | // paths and env keys only. The versioned error envelope is a deliberate | |
| 8 | + | // change from ErrorResponse for app mutations; clients accept both | |
| 9 | + | // during the mixed-version window. | |
| 10 | + | ||
| 11 | + | // AppError is the v2.50 app-mutation error envelope. Its stable fields are | |
| 12 | + | // error/message/cause/hint. | |
| 13 | + | // `logs` never appears on mutations. Clients accept the legacy | |
| 14 | + | // single-field ErrorResponse during the mixed-version window. | |
| 15 | + | type AppError struct { | |
| 16 | + | Error string `json:"error"` | |
| 17 | + | Message string `json:"message,omitempty"` | |
| 18 | + | Cause string `json:"cause,omitempty"` | |
| 19 | + | Hint string `json:"hint,omitempty"` | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // AppDiffSection carries normalized added/removed/changed paths. | |
| 23 | + | type AppDiffSection struct { | |
| 24 | + | Added []string `json:"added"` | |
| 25 | + | Removed []string `json:"removed"` | |
| 26 | + | Changed []string `json:"changed"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // AppApplyRequest asks the daemon to validate and persist desired state. | |
| 30 | + | type AppApplyRequest struct { | |
| 31 | + | ManifestTOML string `json:"manifest_toml"` | |
| 32 | + | DryRun bool `json:"dry_run,omitempty"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // AppApplyResponse reports persistence success separately from deploy. | |
| 36 | + | type AppApplyResponse struct { | |
| 37 | + | App string `json:"app"` | |
| 38 | + | FormerRevision string `json:"former_revision,omitempty"` | |
| 39 | + | ResultingRevision string `json:"resulting_revision,omitempty"` | |
| 40 | + | Pending bool `json:"pending"` | |
| 41 | + | Noop bool `json:"noop,omitempty"` | |
| 42 | + | Diff AppDiffSection `json:"diff"` | |
| 43 | + | Intent string `json:"intent,omitempty"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // AppDeployRequest activates a captured revision. | |
| 47 | + | type AppDeployRequest struct { | |
| 48 | + | Revision string `json:"revision,omitempty"` | |
| 49 | + | Service string `json:"service,omitempty"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // AppServiceResultDTO is one service's terminal deployment result. | |
| 53 | + | type AppServiceResultDTO struct { | |
| 54 | + | Result string `json:"result"` | |
| 55 | + | EffectiveRevision string `json:"effective_revision"` | |
| 56 | + | Before string `json:"before,omitempty"` | |
| 57 | + | After string `json:"after,omitempty"` | |
| 58 | + | RestartUnsafe bool `json:"restart_unsafe"` | |
| 59 | + | Error string `json:"error,omitempty"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // AppStepDTO is one journaled operation step. | |
| 63 | + | type AppStepDTO struct { | |
| 64 | + | ID string `json:"id"` | |
| 65 | + | State string `json:"state"` | |
| 66 | + | Detail string `json:"detail,omitempty"` | |
| 67 | + | Error string `json:"error,omitempty"` | |
| 68 | + | Before string `json:"before,omitempty"` | |
| 69 | + | After string `json:"after,omitempty"` | |
| 70 | + | // Diagnostics is redacted failure output, populated only for callers | |
| 71 | + | // holding the logs read scope. | |
| 72 | + | Diagnostics []string `json:"diagnostics,omitempty"` | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // AppCleanupWarningDTO records post-publication leftovers. | |
| 76 | + | type AppCleanupWarningDTO struct { | |
| 77 | + | Service string `json:"service"` | |
| 78 | + | Leftover string `json:"leftover"` | |
| 79 | + | Detail string `json:"detail"` | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // AppEffectiveDTO maps services to effective revisions. | |
| 83 | + | type AppEffectiveDTO struct { | |
| 84 | + | Converged bool `json:"converged"` | |
| 85 | + | ConvergedRevision string `json:"converged_revision,omitempty"` | |
| 86 | + | Services map[string]string `json:"services"` | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // AppRetainedDTO lists the resources an app owns and would retain on | |
| 90 | + | // removal (names and paths, never values). | |
| 91 | + | type AppRetainedDTO struct { | |
| 92 | + | Volumes []string `json:"volumes"` | |
| 93 | + | Secrets []string `json:"secrets"` | |
| 94 | + | Images []string `json:"images"` | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // AppDeployResponse reports deploy outcome with terminal results. | |
| 98 | + | // Effective and Retained are filled from current app state when the app | |
| 99 | + | // still exists; they are omitted when it does not. | |
| 100 | + | type AppDeployResponse struct { | |
| 101 | + | Op string `json:"op"` | |
| 102 | + | App string `json:"app"` | |
| 103 | + | Revision string `json:"revision"` | |
| 104 | + | Outcome string `json:"outcome"` | |
| 105 | + | Services map[string]AppServiceResultDTO `json:"services"` | |
| 106 | + | Steps []AppStepDTO `json:"steps"` | |
| 107 | + | CleanupWarnings []AppCleanupWarningDTO `json:"cleanup_warnings,omitempty"` | |
| 108 | + | Effective *AppEffectiveDTO `json:"effective,omitempty"` | |
| 109 | + | Retained *AppRetainedDTO `json:"retained,omitempty"` | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // AppDesiredDTO summarizes desired state. | |
| 113 | + | type AppDesiredDTO struct { | |
| 114 | + | Revision string `json:"revision,omitempty"` | |
| 115 | + | Status string `json:"status,omitempty"` | |
| 116 | + | // Pending reports desired state ACTIVE has not reached yet. | |
| 117 | + | Pending bool `json:"pending"` | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // AppActiveServiceDTO is one effective service for inspection. | |
| 121 | + | type AppActiveServiceDTO struct { | |
| 122 | + | EffectiveRevision string `json:"effective_revision"` | |
| 123 | + | Digest string `json:"digest,omitempty"` | |
| 124 | + | Container string `json:"container,omitempty"` | |
| 125 | + | RestartUnsafe bool `json:"restart_unsafe"` | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // AppActiveDTO carries per-service effective state. | |
| 129 | + | type AppActiveDTO struct { | |
| 130 | + | Converged bool `json:"converged"` | |
| 131 | + | ConvergedRevision string `json:"converged_revision,omitempty"` | |
| 132 | + | Services map[string]AppActiveServiceDTO `json:"services"` | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // AppIntentDTO carries durable stopped/running intent. | |
| 136 | + | type AppIntentDTO struct { | |
| 137 | + | Stopped bool `json:"stopped"` | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // AppLastOpDTO references the latest operation. | |
| 141 | + | type AppLastOpDTO struct { | |
| 142 | + | Op string `json:"op"` | |
| 143 | + | Kind string `json:"kind,omitempty"` | |
| 144 | + | Outcome string `json:"outcome,omitempty"` | |
| 145 | + | StartedAt time.Time `json:"started_at,omitzero"` | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // AppShowResponse inspects desired + active + intent + ownership + op ref. | |
| 149 | + | type AppShowResponse struct { | |
| 150 | + | App string `json:"app"` | |
| 151 | + | Desired AppDesiredDTO `json:"desired"` | |
| 152 | + | Active AppActiveDTO `json:"active"` | |
| 153 | + | Intent AppIntentDTO `json:"intent"` | |
| 154 | + | Retained AppRetainedDTO `json:"retained"` | |
| 155 | + | LastOp *AppLastOpDTO `json:"last_op,omitempty"` | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // AppSummaryDTO is one row of the app list. | |
| 159 | + | type AppSummaryDTO struct { | |
| 160 | + | App string `json:"app"` | |
| 161 | + | Desired string `json:"desired,omitempty"` | |
| 162 | + | DesiredStatus string `json:"desired_status,omitempty"` | |
| 163 | + | Active string `json:"active,omitempty"` | |
| 164 | + | Converged bool `json:"converged"` | |
| 165 | + | Pending bool `json:"pending"` | |
| 166 | + | Stopped bool `json:"stopped"` | |
| 167 | + | LastOutcome string `json:"last_outcome,omitempty"` | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // AppDiffResponse reports normalized desired-vs-active diff. | |
| 171 | + | type AppDiffResponse struct { | |
| 172 | + | App string `json:"app"` | |
| 173 | + | Diff AppDiffSection `json:"diff"` | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // AppSecretSetRequest writes secret values (names pre-registered). | |
| 177 | + | type AppSecretSetRequest struct { | |
| 178 | + | Service string `json:"service"` | |
| 179 | + | Secrets map[string]string `json:"secrets"` | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // AppSecretDeleteRequest removes one secret value. | |
| 183 | + | type AppSecretDeleteRequest struct { | |
| 184 | + | Service string `json:"service"` | |
| 185 | + | Key string `json:"key"` | |
| 186 | + | } | |
A internal/adapters/dto/admin_apps_test.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // Frozen-wire tests for the v2.50 app admin DTOs pin the exact JSON field | |
| 4 | + | // names the daemon and the CLI exchange, and prove no response shape can | |
| 5 | + | // carry secret values. The single intentional exception is | |
| 6 | + | // AppSecretSetRequest: the write path must transport values. | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/json" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func dtoJSONKeys(t *testing.T, v any) map[string]any { | |
| 18 | + | t.Helper() | |
| 19 | + | raw, err := json.Marshal(v) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | var decoded map[string]any | |
| 22 | + | require.NoError(t, json.Unmarshal(raw, &decoded)) | |
| 23 | + | return decoded | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAppErrorEnvelopeKeys(t *testing.T) { | |
| 27 | + | envelope := AppError{Error: "secret-missing", Message: "db password missing", Cause: "pass lookup failed", Hint: "set it first"} | |
| 28 | + | keys := dtoJSONKeys(t, envelope) | |
| 29 | + | assert.Equal(t, "secret-missing", keys["error"]) | |
| 30 | + | assert.Equal(t, "db password missing", keys["message"]) | |
| 31 | + | assert.Equal(t, "pass lookup failed", keys["cause"]) | |
| 32 | + | assert.Equal(t, "set it first", keys["hint"]) | |
| 33 | + | assert.NotContains(t, keys, "logs", "mutations must never carry logs") | |
| 34 | + | ||
| 35 | + | // Mixed-version window: the legacy single-field shape still decodes. | |
| 36 | + | var legacy AppError | |
| 37 | + | require.NoError(t, json.Unmarshal([]byte(`{"error":"image-unresolvable"}`), &legacy)) | |
| 38 | + | assert.Equal(t, "image-unresolvable", legacy.Error) | |
| 39 | + | assert.Empty(t, legacy.Message) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestAppApplyResponseKeys(t *testing.T) { | |
| 43 | + | resp := AppApplyResponse{ | |
| 44 | + | App: "blog", | |
| 45 | + | FormerRevision: "rev-old", | |
| 46 | + | ResultingRevision: "rev-new", | |
| 47 | + | Pending: true, | |
| 48 | + | Diff: AppDiffSection{Added: []string{"service.web"}, Removed: []string{}, Changed: []string{}}, | |
| 49 | + | Intent: "apply-abc", | |
| 50 | + | } | |
| 51 | + | keys := dtoJSONKeys(t, resp) | |
| 52 | + | for _, key := range []string{"app", "former_revision", "resulting_revision", "pending", "diff", "intent"} { | |
| 53 | + | assert.Contains(t, keys, key) | |
| 54 | + | } | |
| 55 | + | diff := keys["diff"].(map[string]any) | |
| 56 | + | for _, key := range []string{"added", "removed", "changed"} { | |
| 57 | + | assert.Contains(t, diff, key) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestAppDeployResponseKeys(t *testing.T) { | |
| 62 | + | resp := AppDeployResponse{ | |
| 63 | + | Op: "op-abc", | |
| 64 | + | App: "blog", | |
| 65 | + | Revision: "rev-new", | |
| 66 | + | Outcome: "success", | |
| 67 | + | Services: map[string]AppServiceResultDTO{ | |
| 68 | + | "web": {Result: "deployed", EffectiveRevision: "rev-new"}, | |
| 69 | + | }, | |
| 70 | + | Steps: []AppStepDTO{{ID: "service.web.replace", State: "succeeded"}}, | |
| 71 | + | CleanupWarnings: []AppCleanupWarningDTO{ | |
| 72 | + | {Service: "web", Leftover: "ctr-old", Detail: "retire failed after publish"}, | |
| 73 | + | }, | |
| 74 | + | Effective: &AppEffectiveDTO{Converged: true, Services: map[string]string{"web": "rev-new"}}, | |
| 75 | + | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | |
| 76 | + | } | |
| 77 | + | keys := dtoJSONKeys(t, resp) | |
| 78 | + | for _, key := range []string{"op", "app", "revision", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} { | |
| 79 | + | assert.Contains(t, keys, key) | |
| 80 | + | } | |
| 81 | + | svc := keys["services"].(map[string]any)["web"].(map[string]any) | |
| 82 | + | for _, key := range []string{"result", "effective_revision", "restart_unsafe"} { | |
| 83 | + | assert.Contains(t, svc, key) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestAppShowResponseKeys(t *testing.T) { | |
| 88 | + | resp := AppShowResponse{ | |
| 89 | + | App: "blog", | |
| 90 | + | Desired: AppDesiredDTO{Revision: "rev-new", Status: "pending", Pending: true}, | |
| 91 | + | Active: AppActiveDTO{Converged: false, Services: map[string]AppActiveServiceDTO{ | |
| 92 | + | "web": {EffectiveRevision: "rev-old", Digest: "sha256:abc", Container: "ctr-old", RestartUnsafe: true}, | |
| 93 | + | }}, | |
| 94 | + | Intent: AppIntentDTO{Stopped: false}, | |
| 95 | + | LastOp: &AppLastOpDTO{Op: "op-abc", Outcome: "success"}, | |
| 96 | + | } | |
| 97 | + | keys := dtoJSONKeys(t, resp) | |
| 98 | + | for _, key := range []string{"app", "desired", "active", "intent", "retained", "last_op"} { | |
| 99 | + | assert.Contains(t, keys, key) | |
| 100 | + | } | |
| 101 | + | web := keys["active"].(map[string]any)["services"].(map[string]any)["web"].(map[string]any) | |
| 102 | + | for _, key := range []string{"effective_revision", "digest", "container", "restart_unsafe"} { | |
| 103 | + | assert.Contains(t, web, key) | |
| 104 | + | } | |
| 105 | + | assert.NotContains(t, web, "env", "active services must not inline env values") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // TestAppResponsesCarryNoSecretValues marshals representative responses and | |
| 109 | + | // proves a sentinel secret value appears nowhere. The request that carries | |
| 110 | + | // values (AppSecretSetRequest) is asserted as the single exception. | |
| 111 | + | func TestAppResponsesCarryNoSecretValues(t *testing.T) { | |
| 112 | + | const sentinel = "s3cr3t-value-sentinel" | |
| 113 | + | ||
| 114 | + | responses := []any{ | |
| 115 | + | AppApplyResponse{App: "blog", ResultingRevision: "rev-x", Intent: "apply-x"}, | |
| 116 | + | AppDeployResponse{ | |
| 117 | + | Op: "op-x", App: "blog", Revision: "rev-x", Outcome: "failed", | |
| 118 | + | Services: map[string]AppServiceResultDTO{ | |
| 119 | + | "web": {Result: "failed", EffectiveRevision: "rev-x", Error: "boom"}, | |
| 120 | + | }, | |
| 121 | + | Effective: &AppEffectiveDTO{Services: map[string]string{"web": "rev-x"}}, | |
| 122 | + | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | |
| 123 | + | }, | |
| 124 | + | AppShowResponse{ | |
| 125 | + | App: "blog", | |
| 126 | + | Desired: AppDesiredDTO{Revision: "rev-x", Status: "active"}, | |
| 127 | + | Active: AppActiveDTO{Services: map[string]AppActiveServiceDTO{ | |
| 128 | + | "web": {EffectiveRevision: "rev-x", Digest: "sha256:x", Container: "ctr-x"}, | |
| 129 | + | }}, | |
| 130 | + | }, | |
| 131 | + | AppSummaryDTO{App: "blog", Desired: "rev-x", Active: "rev-x"}, | |
| 132 | + | AppDiffResponse{App: "blog", Diff: AppDiffSection{Changed: []string{"secret.db/password"}}}, | |
| 133 | + | } | |
| 134 | + | for i, resp := range responses { | |
| 135 | + | raw, err := json.Marshal(resp) | |
| 136 | + | require.NoError(t, err, "response %d", i) | |
| 137 | + | body := string(raw) | |
| 138 | + | assert.NotContains(t, body, sentinel, "response %d leaks a secret value", i) | |
| 139 | + | assert.NotContains(t, strings.ToLower(body), `"value"`, "response %d has a value field", i) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // The one intentional exception: the set-secret request transports values. | |
| 143 | + | setReq := AppSecretSetRequest{Service: "web", Secrets: map[string]string{"password": sentinel}} | |
| 144 | + | raw, err := json.Marshal(setReq) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | assert.Contains(t, string(raw), sentinel) | |
| 147 | + | } | |
D internal/adapters/dto/admin_attachments.go +0 -29
| @@ -1,29 +0,0 @@ | |||
| 1 | - | // Package dto provides shared data transfer objects for API responses. | |
| 2 | - | package dto | |
| 3 | - | ||
| 4 | - | // AttachmentsConfigResponse represents all configured attachments. | |
| 5 | - | type AttachmentsConfigResponse struct { | |
| 6 | - | Attachments map[string][]string `json:"attachments"` | |
| 7 | - | } | |
| 8 | - | ||
| 9 | - | // AttachmentConfigResponse represents attachments for a specific target. | |
| 10 | - | type AttachmentConfigResponse struct { | |
| 11 | - | Target string `json:"target"` | |
| 12 | - | Images []string `json:"images"` | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // AttachmentTargetsByImageResponse represents attachment targets for a specific image. | |
| 16 | - | type AttachmentTargetsByImageResponse struct { | |
| 17 | - | Image string `json:"image"` | |
| 18 | - | Targets []string `json:"targets"` | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // AttachmentAddRequest represents a request to add an attachment. | |
| 22 | - | type AttachmentAddRequest struct { | |
| 23 | - | Image string `json:"image"` | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // AttachmentStatusResponse represents attachment operation result. | |
| 27 | - | type AttachmentStatusResponse struct { | |
| 28 | - | Status string `json:"status"` | |
| 29 | - | } | |
D internal/adapters/dto/admin_autoroute.go +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | package dto | |
| 2 | - | ||
| 3 | - | // AutoRouteAllowedDomainsResponse represents the list of allowed domains. | |
| 4 | - | type AutoRouteAllowedDomainsResponse struct { | |
| 5 | - | Domains []string `json:"domains"` | |
| 6 | - | } | |
| 7 | - | ||
| 8 | - | // AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern. | |
| 9 | - | type AutoRouteAllowedDomainRequest struct { | |
| 10 | - | Pattern string `json:"pattern"` | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | // AutoRouteStatusResponse represents a status response for auto-route operations. | |
| 14 | - | type AutoRouteStatusResponse struct { | |
| 15 | - | Status string `json:"status"` | |
| 16 | - | } | |
M internal/adapters/dto/admin_backups.go +30 -39
| @@ -2,11 +2,13 @@ package dto | |||
| 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 | - | } | |
M internal/adapters/dto/admin_secrets.go +2 -9
| @@ -1,16 +1,9 @@ | |||
| 1 | 1 | package dto | |
| 2 | 2 | ||
| 3 | - | // AttachmentSecretsResponse represents secrets for an attachment container. | |
| 4 | - | type AttachmentSecretsResponse struct { | |
| 5 | - | Service string `json:"service"` | |
| 6 | - | Keys []string `json:"keys"` | |
| 7 | - | } | |
| 8 | - | ||
| 9 | 3 | // SecretsListResponse represents a list of secret keys for a domain. | |
| 10 | 4 | type SecretsListResponse struct { | |
| 11 | - | Domain string `json:"domain"` | |
| 12 | - | Keys []string `json:"keys"` | |
| 13 | - | Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"` | |
| 5 | + | Domain string `json:"domain"` | |
| 6 | + | Keys []string `json:"keys"` | |
| 14 | 7 | } | |
| 15 | 8 | ||
| 16 | 9 | // SecretsStatusResponse represents a secrets write response. | |
M internal/adapters/dto/admin_status.go +1 -2
| @@ -2,11 +2,10 @@ package dto | |||
| 2 | 2 | ||
| 3 | 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 +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | // Package appmanifest decodes standalone app TOML files into | |
| 2 | + | // normalized domain.AppSpec values. It performs SHAPE decoding only | |
| 3 | + | // (strict unknown fields, type mapping, defaults); all semantic rules | |
| 4 | + | // live in domain.AppSpec.Validate. It never reads files, environment, | |
| 5 | + | // or secrets beyond the manifest bytes given to it. | |
| 6 | + | package appmanifest | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "bytes" | |
| 10 | + | "fmt" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/pelletier/go-toml/v2" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // rawManifest mirrors the frozen TOML schema for strict decoding. | |
| 20 | + | type rawManifest struct { | |
| 21 | + | Name string `toml:"name"` | |
| 22 | + | Env map[string]string `toml:"env"` | |
| 23 | + | Services []rawService `toml:"service"` | |
| 24 | + | Networks []rawSharedNetwork `toml:"network.shared"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // rawService mirrors one [[service]] table. | |
| 28 | + | type rawService struct { | |
| 29 | + | Name string `toml:"name"` | |
| 30 | + | Image string `toml:"image"` | |
| 31 | + | Command []string `toml:"command"` | |
| 32 | + | StopGrace string `toml:"stop_grace"` | |
| 33 | + | Env map[string]string `toml:"env"` | |
| 34 | + | Readiness rawReadiness `toml:"readiness"` | |
| 35 | + | HTTP []rawHTTP `toml:"http"` | |
| 36 | + | TCP []rawTCP `toml:"tcp"` | |
| 37 | + | UDP []rawUDP `toml:"udp"` | |
| 38 | + | Secrets map[string]string `toml:"secrets"` | |
| 39 | + | Volumes []rawVolume `toml:"volume"` | |
| 40 | + | Databases []rawDatabase `toml:"database"` | |
| 41 | + | Backup rawBackup `toml:"backup"` | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // rawReadiness mirrors [service.readiness]. | |
| 45 | + | type rawReadiness struct { | |
| 46 | + | Type string `toml:"type"` | |
| 47 | + | Path string `toml:"path"` | |
| 48 | + | Contains string `toml:"contains"` | |
| 49 | + | Port int `toml:"port"` | |
| 50 | + | Timeout string `toml:"timeout"` | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // rawHTTP mirrors [[service.http]]. | |
| 54 | + | type rawHTTP struct { | |
| 55 | + | Host string `toml:"host"` | |
| 56 | + | Port int `toml:"port"` | |
| 57 | + | TLS string `toml:"tls"` | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // rawTCP mirrors [[service.tcp]]. | |
| 61 | + | type rawTCP struct { | |
| 62 | + | Entrypoint string `toml:"entrypoint"` | |
| 63 | + | Port int `toml:"port"` | |
| 64 | + | Publish string `toml:"publish"` | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // rawUDP mirrors [[service.udp]]. | |
| 68 | + | type rawUDP struct { | |
| 69 | + | Entrypoint string `toml:"entrypoint"` | |
| 70 | + | Port int `toml:"port"` | |
| 71 | + | Publish string `toml:"publish"` | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // rawVolume mirrors [[service.volume]]. | |
| 75 | + | type rawVolume struct { | |
| 76 | + | Name string `toml:"name"` | |
| 77 | + | Path string `toml:"path"` | |
| 78 | + | ReadOnly bool `toml:"readonly"` | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // rawDatabase mirrors [[service.database]]. | |
| 82 | + | type rawDatabase struct { | |
| 83 | + | Name string `toml:"name"` | |
| 84 | + | Type string `toml:"type"` | |
| 85 | + | Schedule string `toml:"schedule"` | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // rawBackup mirrors [service.backup]. | |
| 89 | + | type rawBackup struct { | |
| 90 | + | Postgres []string `toml:"postgres"` | |
| 91 | + | Volume []string `toml:"volume"` | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // rawSharedNetwork mirrors [[network.shared]]. | |
| 95 | + | type rawSharedNetwork struct { | |
| 96 | + | Network string `toml:"network"` | |
| 97 | + | Services []string `toml:"services"` | |
| 98 | + | Aliases []string `toml:"aliases"` | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Parse decodes manifest TOML bytes into a validated, normalized AppSpec. | |
| 102 | + | // sourceName is used for mismatch warnings only; the name field is authoritative. | |
| 103 | + | func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) { | |
| 104 | + | var raw rawManifest | |
| 105 | + | decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields() | |
| 106 | + | if err := decoder.Decode(&raw); err != nil { | |
| 107 | + | return domain.AppSpec{}, nil, fmt.Errorf("%w: %v", domain.ErrInvalidAppSpec, err) | |
| 108 | + | } | |
| 109 | + | spec, err := toDomain(raw) | |
| 110 | + | if err != nil { | |
| 111 | + | return domain.AppSpec{}, nil, err | |
| 112 | + | } | |
| 113 | + | var warnings []string | |
| 114 | + | if sourceName != "" && sourceName != spec.Name && sourceName != spec.Name+".toml" { | |
| 115 | + | warnings = append(warnings, fmt.Sprintf("file name %q does not match app name %q", sourceName, spec.Name)) | |
| 116 | + | } | |
| 117 | + | if err := spec.Validate(); err != nil { | |
| 118 | + | return domain.AppSpec{}, warnings, err | |
| 119 | + | } | |
| 120 | + | return spec, warnings, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // toDomain maps raw TOML onto domain types with normalization and defaults. | |
| 124 | + | func toDomain(raw rawManifest) (domain.AppSpec, error) { | |
| 125 | + | spec := domain.AppSpec{ | |
| 126 | + | Name: raw.Name, | |
| 127 | + | Env: map[string]string{}, | |
| 128 | + | Services: make([]domain.AppService, 0, len(raw.Services)), | |
| 129 | + | Networks: make([]domain.AppSharedNetwork, 0, len(raw.Networks)), | |
| 130 | + | } | |
| 131 | + | for key, value := range raw.Env { | |
| 132 | + | spec.Env[key] = value | |
| 133 | + | } | |
| 134 | + | for i := range raw.Services { | |
| 135 | + | svc, err := toDomainService(raw.Services[i]) | |
| 136 | + | if err != nil { | |
| 137 | + | return domain.AppSpec{}, err | |
| 138 | + | } | |
| 139 | + | spec.Services = append(spec.Services, svc) | |
| 140 | + | } | |
| 141 | + | for _, net := range raw.Networks { | |
| 142 | + | spec.Networks = append(spec.Networks, domain.AppSharedNetwork{ | |
| 143 | + | Network: net.Network, | |
| 144 | + | Services: append([]string(nil), net.Services...), | |
| 145 | + | Aliases: append([]string(nil), net.Aliases...), | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | return spec, nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // toDomainService maps one raw service with defaults. | |
| 152 | + | func toDomainService(raw rawService) (domain.AppService, error) { | |
| 153 | + | svc := domain.AppService{ | |
| 154 | + | Name: raw.Name, | |
| 155 | + | Image: normalizeImage(raw.Image), | |
| 156 | + | Command: append([]string(nil), raw.Command...), | |
| 157 | + | Secrets: map[string]string{}, | |
| 158 | + | Backup: domain.AppBackup{ | |
| 159 | + | Postgres: append([]string(nil), raw.Backup.Postgres...), | |
| 160 | + | Volume: append([]string(nil), raw.Backup.Volume...), | |
| 161 | + | }, | |
| 162 | + | } | |
| 163 | + | stopGrace := domain.AppDefaultStopGrace | |
| 164 | + | if raw.StopGrace != "" { | |
| 165 | + | parsed, err := time.ParseDuration(raw.StopGrace) | |
| 166 | + | if err != nil { | |
| 167 | + | return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, raw.Name, raw.StopGrace, err) | |
| 168 | + | } | |
| 169 | + | stopGrace = parsed | |
| 170 | + | } | |
| 171 | + | svc.StopGrace = stopGrace | |
| 172 | + | readiness, err := toDomainReadiness(raw.Name, raw.Readiness) | |
| 173 | + | if err != nil { | |
| 174 | + | return domain.AppService{}, err | |
| 175 | + | } | |
| 176 | + | svc.Readiness = readiness | |
| 177 | + | for _, h := range raw.HTTP { | |
| 178 | + | tls := h.TLS | |
| 179 | + | if tls == "" { | |
| 180 | + | tls = domain.AppTLSAuto | |
| 181 | + | } | |
| 182 | + | svc.HTTP = append(svc.HTTP, domain.AppHTTPInterface{ | |
| 183 | + | Host: domain.CanonicalHTTPHost(h.Host), | |
| 184 | + | Port: h.Port, | |
| 185 | + | TLS: tls, | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | for _, t := range raw.TCP { | |
| 189 | + | svc.TCP = append(svc.TCP, domain.AppTCPInterface{ | |
| 190 | + | Entrypoint: t.Entrypoint, | |
| 191 | + | Port: t.Port, | |
| 192 | + | Publish: t.Publish, | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | for _, u := range raw.UDP { | |
| 196 | + | svc.UDP = append(svc.UDP, domain.AppUDPInterface{ | |
| 197 | + | Entrypoint: u.Entrypoint, | |
| 198 | + | Port: u.Port, | |
| 199 | + | Publish: u.Publish, | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | for key, value := range raw.Secrets { | |
| 203 | + | svc.Secrets[key] = value | |
| 204 | + | } | |
| 205 | + | for _, v := range raw.Volumes { | |
| 206 | + | svc.Volumes = append(svc.Volumes, domain.AppVolume{ | |
| 207 | + | Name: v.Name, | |
| 208 | + | Path: v.Path, | |
| 209 | + | ReadOnly: v.ReadOnly, | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | for _, db := range raw.Databases { | |
| 213 | + | svc.Databases = append(svc.Databases, domain.AppDatabase{ | |
| 214 | + | Name: db.Name, | |
| 215 | + | Type: db.Type, | |
| 216 | + | Schedule: db.Schedule, | |
| 217 | + | }) | |
| 218 | + | } | |
| 219 | + | if len(raw.Env) > 0 { | |
| 220 | + | return domain.AppService{}, fmt.Errorf("%w: service %q [service.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, raw.Name) | |
| 221 | + | } | |
| 222 | + | return svc, nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // toDomainReadiness maps readiness with defaults. | |
| 226 | + | func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) { | |
| 227 | + | readiness := domain.AppReadiness{ | |
| 228 | + | Type: raw.Type, | |
| 229 | + | Path: raw.Path, | |
| 230 | + | Contains: raw.Contains, | |
| 231 | + | Port: raw.Port, | |
| 232 | + | Timeout: domain.AppDefaultReadinessTimeout, | |
| 233 | + | } | |
| 234 | + | if readiness.Type == "" { | |
| 235 | + | readiness.Type = domain.AppReadinessNone | |
| 236 | + | } | |
| 237 | + | if raw.Timeout != "" { | |
| 238 | + | parsed, err := time.ParseDuration(raw.Timeout) | |
| 239 | + | if err != nil { | |
| 240 | + | return domain.AppReadiness{}, fmt.Errorf("%w: service %q readiness timeout %q is invalid: %v", domain.ErrInvalidAppSpec, service, raw.Timeout, err) | |
| 241 | + | } | |
| 242 | + | readiness.Timeout = parsed | |
| 243 | + | } | |
| 244 | + | return readiness, nil | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // normalizeImage records a missing tag as explicit :latest. | |
| 248 | + | func normalizeImage(image string) string { | |
| 249 | + | image = strings.TrimSpace(image) | |
| 250 | + | if image == "" { | |
| 251 | + | return image | |
| 252 | + | } | |
| 253 | + | if strings.Contains(image, "@") { | |
| 254 | + | return image | |
| 255 | + | } | |
| 256 | + | lastSlash := strings.LastIndex(image, "/") | |
| 257 | + | lastColon := strings.LastIndex(image, ":") | |
| 258 | + | if lastColon <= lastSlash { | |
| 259 | + | return image + ":latest" | |
| 260 | + | } | |
| 261 | + | return image | |
| 262 | + | } | |
A internal/adapters/in/appmanifest/parse_test.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package appmanifest_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/in/appmanifest" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const validWeb = ` | |
| 14 | + | name = "blog" | |
| 15 | + | ||
| 16 | + | [env] | |
| 17 | + | APP_ENV = "production" | |
| 18 | + | ||
| 19 | + | [[service]] | |
| 20 | + | name = "web" | |
| 21 | + | image = "registry.example.com/blog/web:1.4.2" | |
| 22 | + | ||
| 23 | + | [service.readiness] | |
| 24 | + | type = "http" | |
| 25 | + | path = "/healthz" | |
| 26 | + | ||
| 27 | + | [[service.http]] | |
| 28 | + | host = "Blog.Example.COM." | |
| 29 | + | port = 8080 | |
| 30 | + | ||
| 31 | + | [service.secrets] | |
| 32 | + | DATABASE_URL = "database-url" | |
| 33 | + | ` | |
| 34 | + | ||
| 35 | + | func TestParse_ValidWeb(t *testing.T) { | |
| 36 | + | spec, warnings, err := appmanifest.Parse([]byte(validWeb), "blog.toml") | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.Empty(t, warnings) | |
| 39 | + | assert.Equal(t, "blog", spec.Name) | |
| 40 | + | assert.Equal(t, "production", spec.Env["APP_ENV"]) | |
| 41 | + | require.Len(t, spec.Services, 1) | |
| 42 | + | svc := spec.Services[0] | |
| 43 | + | assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image) | |
| 44 | + | assert.Equal(t, "blog.example.com", svc.HTTP[0].Host) | |
| 45 | + | assert.Equal(t, "auto", svc.HTTP[0].TLS) | |
| 46 | + | assert.Equal(t, "http", svc.Readiness.Type) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestParse_FileNameMismatchIsWarning(t *testing.T) { | |
| 50 | + | _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml") | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.Len(t, warnings, 1) | |
| 53 | + | assert.Contains(t, warnings[0], "other.toml") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestParse_MissingTagNormalizesToLatest(t *testing.T) { | |
| 57 | + | doc := ` | |
| 58 | + | name = "blog" | |
| 59 | + | [[service]] | |
| 60 | + | name = "web" | |
| 61 | + | image = "registry.example.com/blog/web" | |
| 62 | + | [[service.http]] | |
| 63 | + | host = "blog.example.com" | |
| 64 | + | port = 8080 | |
| 65 | + | ` | |
| 66 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Equal(t, "registry.example.com/blog/web:latest", spec.Services[0].Image) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestParse_UnknownFieldsRejected(t *testing.T) { | |
| 72 | + | cases := []struct { | |
| 73 | + | name string | |
| 74 | + | doc string | |
| 75 | + | }{ | |
| 76 | + | {"top level", "name = \"blog\"\nbogus = 1\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n"}, | |
| 77 | + | {"service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nbogus = 1\n"}, | |
| 78 | + | {"readiness", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.readiness]\nbogus = 1\n"}, | |
| 79 | + | {"http", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"}, | |
| 80 | + | } | |
| 81 | + | for _, tc := range cases { | |
| 82 | + | t.Run(tc.name, func(t *testing.T) { | |
| 83 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 84 | + | require.Error(t, err) | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestParse_ServiceEnvRejected(t *testing.T) { | |
| 90 | + | doc := ` | |
| 91 | + | name = "blog" | |
| 92 | + | [[service]] | |
| 93 | + | name = "web" | |
| 94 | + | image = "img:1" | |
| 95 | + | [service.env] | |
| 96 | + | FOO = "bar" | |
| 97 | + | ` | |
| 98 | + | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 99 | + | require.ErrorContains(t, err, "[service.env]") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestParse_Names(t *testing.T) { | |
| 103 | + | cases := []struct { | |
| 104 | + | name string | |
| 105 | + | doc string | |
| 106 | + | wantErr string | |
| 107 | + | }{ | |
| 108 | + | {"bad app", "name = \"Blog!\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "app name"}, | |
| 109 | + | {"double dash app", "name = \"a--b\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "--"}, | |
| 110 | + | {"reserved app", "name = \"gordon\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "reserved"}, | |
| 111 | + | {"no services", "name = \"blog\"\n", "at least one service"}, | |
| 112 | + | {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"}, | |
| 113 | + | {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"}, | |
| 114 | + | {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"}, | |
| 115 | + | {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "missing in the target struct"}, | |
| 116 | + | {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"}, | |
| 117 | + | {"shared volume", "name = \"blog\"\n[[service]]\nname = \"a\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n[[service]]\nname = \"b\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"}, | |
| 118 | + | {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"}, | |
| 119 | + | {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "secret references"}, | |
| 120 | + | } | |
| 121 | + | for _, tc := range cases { | |
| 122 | + | t.Run(tc.name, func(t *testing.T) { | |
| 123 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 124 | + | require.ErrorContains(t, err, tc.wantErr) | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestParse_Readiness(t *testing.T) { | |
| 130 | + | cases := []struct { | |
| 131 | + | name string | |
| 132 | + | doc string | |
| 133 | + | wantErr string | |
| 134 | + | }{ | |
| 135 | + | {"bad type", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"}, | |
| 136 | + | {"log needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"}, | |
| 137 | + | {"http needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[service.readiness]\ntype = \"http\"\n", "http readiness requires path"}, | |
| 138 | + | {"udp tcp rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[service.readiness]\ntype = \"tcp\"\n", "UDP-only"}, | |
| 139 | + | {"multi tcp needs port", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[[service.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[service.readiness]\ntype = \"tcp\"\n", "readiness.port is required"}, | |
| 140 | + | {"bad port ref", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"b.example.com\"\nport = 8080\n[service.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"}, | |
| 141 | + | {"bad timeout", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntimeout = \"99h\"\n", "timeout must be within"}, | |
| 142 | + | } | |
| 143 | + | for _, tc := range cases { | |
| 144 | + | t.Run(tc.name, func(t *testing.T) { | |
| 145 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml") | |
| 146 | + | require.ErrorContains(t, err, tc.wantErr) | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestParse_Interfaces(t *testing.T) { | |
| 152 | + | cases := []struct { | |
| 153 | + | name string | |
| 154 | + | doc string | |
| 155 | + | wantErr string | |
| 156 | + | }{ | |
| 157 | + | {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"}, | |
| 158 | + | {"publish hostname", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"}, | |
| 159 | + | {"rcon table rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "missing in the target struct"}, | |
| 160 | + | {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"}, | |
| 161 | + | } | |
| 162 | + | for _, tc := range cases { | |
| 163 | + | t.Run(tc.name, func(t *testing.T) { | |
| 164 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml") | |
| 165 | + | require.ErrorContains(t, err, tc.wantErr) | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestParse_Backups(t *testing.T) { | |
| 171 | + | doc := ` | |
| 172 | + | name = "shop" | |
| 173 | + | [[service]] | |
| 174 | + | name = "api" | |
| 175 | + | image = "registry.example.com/shop/api:2.0.0" | |
| 176 | + | [[service.http]] | |
| 177 | + | host = "shop.example.com" | |
| 178 | + | port = 8080 | |
| 179 | + | [[service.database]] | |
| 180 | + | name = "main" | |
| 181 | + | type = "postgres" | |
| 182 | + | schedule = "daily" | |
| 183 | + | [service.backup] | |
| 184 | + | postgres = ["main"] | |
| 185 | + | ` | |
| 186 | + | spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml") | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | assert.Equal(t, "daily", spec.Services[0].Databases[0].Schedule) | |
| 189 | + | ||
| 190 | + | badRef := strings.Replace(doc, "postgres = [\"main\"]", "postgres = [\"ghost\"]", 1) | |
| 191 | + | _, _, err = appmanifest.Parse([]byte(badRef), "shop.toml") | |
| 192 | + | require.ErrorContains(t, err, "unknown database") | |
| 193 | + | ||
| 194 | + | badSchedule := strings.Replace(doc, "schedule = \"daily\"", "schedule = \"minutely\"", 1) | |
| 195 | + | _, _, err = appmanifest.Parse([]byte(badSchedule), "shop.toml") | |
| 196 | + | require.ErrorContains(t, err, "schedule must be") | |
| 197 | + | ||
| 198 | + | badEngine := strings.Replace(doc, "type = \"postgres\"", "type = \"mysql\"", 1) | |
| 199 | + | _, _, err = appmanifest.Parse([]byte(badEngine), "shop.toml") | |
| 200 | + | require.ErrorContains(t, err, "must be postgres") | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestParse_GameAndStagingExamples(t *testing.T) { | |
| 204 | + | game := ` | |
| 205 | + | name = "rust" | |
| 206 | + | [[service]] | |
| 207 | + | name = "server" | |
| 208 | + | image = "registry.example.com/games/rust:2026.09" | |
| 209 | + | [service.readiness] | |
| 210 | + | type = "log" | |
| 211 | + | path = "/data/logs/server.log" | |
| 212 | + | contains = "Server startup complete" | |
| 213 | + | timeout = "5m" | |
| 214 | + | [[service.udp]] | |
| 215 | + | entrypoint = "udp" | |
| 216 | + | port = 28015 | |
| 217 | + | publish = "0.0.0.0:28015" | |
| 218 | + | [[service.tcp]] | |
| 219 | + | entrypoint = "tcp" | |
| 220 | + | port = 28016 | |
| 221 | + | publish = "127.0.0.1:28016" | |
| 222 | + | [[service.volume]] | |
| 223 | + | name = "rust-data" | |
| 224 | + | path = "/data" | |
| 225 | + | ` | |
| 226 | + | spec, _, err := appmanifest.Parse([]byte(game), "rust.toml") | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | assert.Equal(t, "log", spec.Services[0].Readiness.Type) | |
| 229 | + | require.Len(t, spec.Services[0].TCP, 1) | |
| 230 | + | assert.Equal(t, 28016, spec.Services[0].TCP[0].Port) | |
| 231 | + | ||
| 232 | + | staging := ` | |
| 233 | + | name = "blog-staging" | |
| 234 | + | [env] | |
| 235 | + | APP_ENV = "staging" | |
| 236 | + | [[service]] | |
| 237 | + | name = "web" | |
| 238 | + | image = "registry.example.com/blog/web:1.4.2-rc.1" | |
| 239 | + | [[service.http]] | |
| 240 | + | host = "staging-blog.example.com" | |
| 241 | + | port = 8080 | |
| 242 | + | ` | |
| 243 | + | spec, _, err = appmanifest.Parse([]byte(staging), "blog-staging.toml") | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | assert.Equal(t, "blog-staging", spec.Name) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestParse_NoFileEnvSecretReads(t *testing.T) { | |
| 249 | + | // Parser input is bytes only; there is no path parameter that could | |
| 250 | + | // trigger file, env, or secret reads beyond the manifest input. | |
| 251 | + | spec, _, err := appmanifest.Parse([]byte(validWeb), "") | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | assert.Equal(t, "blog", spec.Name) | |
| 254 | + | } | |
A internal/adapters/in/cli/apps.go +1014 -0
| @@ -0,0 +1,1014 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | // App CLI surface for declarative app operations. | |
| 4 | + | // | |
| 5 | + | // Conventions (per AGENTS.md): cmd.Context(), cmd.OutOrStdout(), errors | |
| 6 | + | // from RunE, --json via writeJSON with equivalent semantics, stable | |
| 7 | + | // sorted ordering, and NO secret values or sensitive output anywhere. | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "context" | |
| 11 | + | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "io" | |
| 14 | + | "os" | |
| 15 | + | "sort" | |
| 16 | + | "strings" | |
| 17 | + | ||
| 18 | + | "github.com/spf13/cobra" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 21 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2). | |
| 25 | + | const appApplyMaxBytes = 1 << 20 | |
| 26 | + | ||
| 27 | + | // newLocalAppClient constructs the owner-only local daemon client. It is a | |
| 28 | + | // variable so command tests can inject a socket-backed or unavailable client. | |
| 29 | + | var newLocalAppClient = remote.NewLocalClient | |
| 30 | + | ||
| 31 | + | // resolveAppClient returns the daemon client for app operations. An explicit | |
| 32 | + | // remote (flag or GORDON_REMOTE) is authoritative and never falls back to the | |
| 33 | + | // local socket; otherwise the owner-only local admin socket is used. | |
| 34 | + | func resolveAppClient() (*remote.Client, error) { | |
| 35 | + | client, isRemote, err := GetRemoteClient() | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | if isRemote { | |
| 40 | + | return client, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | localClient, err := newLocalAppClient() | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, fmt.Errorf( | |
| 46 | + | "daemon-unavailable: no daemon endpoint is reachable; app operations "+ | |
| 47 | + | "are daemon-owned and have no local-write fallback "+ | |
| 48 | + | "(start the daemon or pass --remote): %w", err) | |
| 49 | + | } | |
| 50 | + | return localClient, nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // resolveAppPlane returns the daemon-backed control plane for app commands. | |
| 54 | + | // App operations are daemon-owned for BOTH local and remote paths | |
| 55 | + | // (05-api-cli.md §1): the explicit remote when one is selected, otherwise | |
| 56 | + | // the owner-only local admin socket. When neither is reachable the command | |
| 57 | + | // fails with daemon-unavailable — there is no local-write fallback. The | |
| 58 | + | // returned handle owns the client and must be closed by the caller. | |
| 59 | + | func resolveAppPlane() (*controlPlaneHandle, error) { | |
| 60 | + | client, err := resolveAppClient() | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | return &controlPlaneHandle{plane: NewRemoteControlPlane(client)}, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // appMutationError translates ambiguous transport outcomes into the | |
| 68 | + | // outcome-unknown guidance: never blindly retry, re-query by key first. | |
| 69 | + | func appMutationError(op, app, key string, err error) error { | |
| 70 | + | var unknown *remote.OutcomeUnknownError | |
| 71 | + | if errors.As(err, &unknown) { | |
| 72 | + | return fmt.Errorf( | |
| 73 | + | "outcome-unknown: %s %s may have executed; query "+ | |
| 74 | + | "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w", | |
| 75 | + | op, app, app, key, err) | |
| 76 | + | } | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // renderAppOpConflict renders the journaled 409 deploy/lifecycle response | |
| 81 | + | // carried by remote.AppOpConflictError, then returns the terminal failure | |
| 82 | + | // (nonzero exit) with the operation key and by-key recovery guidance. It | |
| 83 | + | // reports false when err is not a journaled conflict so callers fall | |
| 84 | + | // through to appMutationError. | |
| 85 | + | func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut bool) (error, bool) { | |
| 86 | + | var conflict *remote.AppOpConflictError | |
| 87 | + | if !errors.As(err, &conflict) { | |
| 88 | + | return nil, false | |
| 89 | + | } | |
| 90 | + | if jsonOut { | |
| 91 | + | if werr := writeJSON(out, conflict.Response); werr != nil { | |
| 92 | + | return werr, true | |
| 93 | + | } | |
| 94 | + | } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil { | |
| 95 | + | return rerr, true | |
| 96 | + | } | |
| 97 | + | return fmt.Errorf( | |
| 98 | + | "%s of %s did not succeed (outcome %s, op %s); journal rendered above; query "+ | |
| 99 | + | "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w", | |
| 100 | + | op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // newAppsCmd creates the `apps` parent command. | |
| 104 | + | func newAppsCmd() *cobra.Command { | |
| 105 | + | cmd := &cobra.Command{ | |
| 106 | + | Use: "apps", | |
| 107 | + | Short: "Manage applications (daemon-owned)", | |
| 108 | + | Long: `Validate, persist, inspect, and operate applications. | |
| 109 | + | ||
| 110 | + | All mutations are executed by the daemon through the admin API; | |
| 111 | + | without a reachable daemon the commands fail instead of writing locally. | |
| 112 | + | ||
| 113 | + | Examples: | |
| 114 | + | gordon apps apply --file blog.toml | |
| 115 | + | gordon apps list | |
| 116 | + | gordon apps show blog | |
| 117 | + | gordon apps diff blog`, | |
| 118 | + | } | |
| 119 | + | cmd.AddCommand( | |
| 120 | + | newAppsApplyCmd(), | |
| 121 | + | newAppsListCmd(), | |
| 122 | + | newAppsShowCmd(), | |
| 123 | + | newAppsDiffCmd(), | |
| 124 | + | newAppsSecretsCmd(), | |
| 125 | + | newAppDeployCmd(), | |
| 126 | + | newAppRestartCmd(), | |
| 127 | + | newAppStopCmd(), | |
| 128 | + | newAppStartCmd(), | |
| 129 | + | newAppRemoveCmd(), | |
| 130 | + | newAppStatusCmd(), | |
| 131 | + | newAppLogsCmd(), | |
| 132 | + | ) | |
| 133 | + | return cmd | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // newAppsApplyCmd creates `apps apply`. | |
| 137 | + | func newAppsApplyCmd() *cobra.Command { | |
| 138 | + | var file string | |
| 139 | + | var dryRun bool | |
| 140 | + | var chainDeploy bool | |
| 141 | + | var jsonOut bool | |
| 142 | + | cmd := &cobra.Command{ | |
| 143 | + | Use: "apply", | |
| 144 | + | Short: "Validate and persist an app manifest", | |
| 145 | + | Long: `Validates a manifest file and persists it as desired state (or dry-runs). | |
| 146 | + | ||
| 147 | + | With --deploy, chains exactly the accepted revision into a deploy after | |
| 148 | + | persistence succeeds; the two outcomes are reported separately because a | |
| 149 | + | deploy may fail after the apply succeeded.`, | |
| 150 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 151 | + | handle, err := resolveAppPlane() | |
| 152 | + | if err != nil { | |
| 153 | + | return err | |
| 154 | + | } | |
| 155 | + | defer handle.close() | |
| 156 | + | plane := handle.plane | |
| 157 | + | return runAppsApply(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), file, dryRun, chainDeploy, jsonOut) | |
| 158 | + | }, | |
| 159 | + | } | |
| 160 | + | cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)") | |
| 161 | + | cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate without persisting") | |
| 162 | + | cmd.Flags().BoolVar(&chainDeploy, "deploy", false, "Deploy the accepted revision after applying") | |
| 163 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 164 | + | return cmd | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error { | |
| 168 | + | if dryRun && chainDeploy { | |
| 169 | + | return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy") | |
| 170 | + | } | |
| 171 | + | if file == "" { | |
| 172 | + | return fmt.Errorf("missing required flag --file") | |
| 173 | + | } | |
| 174 | + | data, err := os.ReadFile(file) | |
| 175 | + | if err != nil { | |
| 176 | + | return fmt.Errorf("failed to read manifest file: %w", err) | |
| 177 | + | } | |
| 178 | + | if len(data) > appApplyMaxBytes { | |
| 179 | + | return fmt.Errorf("manifest exceeds the 1 MiB apply bound (%d bytes)", len(data)) | |
| 180 | + | } | |
| 181 | + | resp, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: string(data), DryRun: dryRun}) | |
| 182 | + | if err != nil { | |
| 183 | + | return err | |
| 184 | + | } | |
| 185 | + | if !chainDeploy { | |
| 186 | + | if jsonOut { | |
| 187 | + | return writeJSON(out, resp) | |
| 188 | + | } | |
| 189 | + | return renderAppApply(out, resp, dryRun) | |
| 190 | + | } | |
| 191 | + | if !jsonOut { | |
| 192 | + | if err := renderAppApply(out, resp, dryRun); err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | deployResp, key, deployErr := plane.DeployApp(ctx, resp.App, dto.AppDeployRequest{Revision: resp.ResultingRevision}) | |
| 197 | + | if deployErr != nil { | |
| 198 | + | if conflictErr, ok := renderAppOpConflict(out, "deploy", resp.App, key, deployErr, jsonOut); ok { | |
| 199 | + | return fmt.Errorf("apply of %s succeeded (%s); %w", | |
| 200 | + | resp.App, resp.ResultingRevision, conflictErr) | |
| 201 | + | } | |
| 202 | + | return fmt.Errorf("apply of %s succeeded (%s); %w", | |
| 203 | + | resp.App, resp.ResultingRevision, appMutationError("deploy", resp.App, key, deployErr)) | |
| 204 | + | } | |
| 205 | + | if jsonOut { | |
| 206 | + | return writeJSON(out, struct { | |
| 207 | + | Apply *dto.AppApplyResponse `json:"apply"` | |
| 208 | + | Deploy *dto.AppDeployResponse `json:"deploy"` | |
| 209 | + | }{Apply: resp, Deploy: deployResp}) | |
| 210 | + | } | |
| 211 | + | return renderAppDeployResponse(out, deployResp) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error { | |
| 215 | + | if resp.Noop { | |
| 216 | + | return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision)))) | |
| 217 | + | } | |
| 218 | + | verb := "Applied" | |
| 219 | + | if dryRun { | |
| 220 | + | verb = "Validated (dry-run)" | |
| 221 | + | } | |
| 222 | + | if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("%s %s%s", verb, resp.App, appRevisionSuffix(resp.ResultingRevision)))); err != nil { | |
| 223 | + | return err | |
| 224 | + | } | |
| 225 | + | if err := renderAppDiffSection(out, resp.Diff); err != nil { | |
| 226 | + | return err | |
| 227 | + | } | |
| 228 | + | if resp.Pending { | |
| 229 | + | return cliWriteLine(out, cliRenderMeta("intent:", resp.Intent)) | |
| 230 | + | } | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // appRevisionSuffix omits the parenthesized revision when empty (dry-run | |
| 235 | + | // validation may not produce a revision). | |
| 236 | + | func appRevisionSuffix(revision string) string { | |
| 237 | + | if revision == "" { | |
| 238 | + | return "" | |
| 239 | + | } | |
| 240 | + | return " (" + revision + ")" | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // renderAppDiffSection renders normalized diff paths using the same | |
| 244 | + | // added/removed/changed sections as `apps diff`; empty diffs render nothing. | |
| 245 | + | func renderAppDiffSection(out io.Writer, diff dto.AppDiffSection) error { | |
| 246 | + | if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0 { | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | for _, section := range []struct { | |
| 250 | + | title string | |
| 251 | + | paths []string | |
| 252 | + | }{ | |
| 253 | + | {"added:", diff.Added}, | |
| 254 | + | {"removed:", diff.Removed}, | |
| 255 | + | {"changed:", diff.Changed}, | |
| 256 | + | } { | |
| 257 | + | paths := append([]string(nil), section.paths...) | |
| 258 | + | sort.Strings(paths) | |
| 259 | + | for _, p := range paths { | |
| 260 | + | if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // newAppsListCmd creates `apps list`. | |
| 269 | + | func newAppsListCmd() *cobra.Command { | |
| 270 | + | var jsonOut bool | |
| 271 | + | cmd := &cobra.Command{ | |
| 272 | + | Use: "list", | |
| 273 | + | Short: "List applications", | |
| 274 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 275 | + | handle, err := resolveAppPlane() | |
| 276 | + | if err != nil { | |
| 277 | + | return err | |
| 278 | + | } | |
| 279 | + | defer handle.close() | |
| 280 | + | plane := handle.plane | |
| 281 | + | return runAppsList(cmd.Context(), plane, cmd.OutOrStdout(), jsonOut) | |
| 282 | + | }, | |
| 283 | + | } | |
| 284 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 285 | + | return cmd | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func runAppsList(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error { | |
| 289 | + | apps, err := plane.ListApps(ctx) | |
| 290 | + | if err != nil { | |
| 291 | + | return err | |
| 292 | + | } | |
| 293 | + | if jsonOut { | |
| 294 | + | if apps == nil { | |
| 295 | + | apps = []dto.AppSummaryDTO{} | |
| 296 | + | } | |
| 297 | + | return writeJSON(out, apps) | |
| 298 | + | } | |
| 299 | + | if len(apps) == 0 { | |
| 300 | + | return cliWriteLine(out, cliRenderEmptyState("No apps.")) | |
| 301 | + | } | |
| 302 | + | names := make([]string, 0, len(apps)) | |
| 303 | + | byName := make(map[string]dto.AppSummaryDTO, len(apps)) | |
| 304 | + | for _, a := range apps { | |
| 305 | + | names = append(names, a.App) | |
| 306 | + | byName[a.App] = a | |
| 307 | + | } | |
| 308 | + | sort.Strings(names) | |
| 309 | + | for _, name := range names { | |
| 310 | + | a := byName[name] | |
| 311 | + | state := "active" | |
| 312 | + | switch { | |
| 313 | + | case a.Stopped: | |
| 314 | + | state = "stopped" | |
| 315 | + | case a.Pending: | |
| 316 | + | state = "pending" | |
| 317 | + | case a.Active == "": | |
| 318 | + | state = "applied" | |
| 319 | + | case a.LastOutcome != "" && a.LastOutcome != "success": | |
| 320 | + | state += " (" + a.LastOutcome + ")" | |
| 321 | + | } | |
| 322 | + | if err := cliWriteLine(out, cliRenderListItem(fmt.Sprintf("%s (%s)", name, state))); err != nil { | |
| 323 | + | return err | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | return nil | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // newAppsShowCmd creates `apps show`. | |
| 330 | + | func newAppsShowCmd() *cobra.Command { | |
| 331 | + | var jsonOut bool | |
| 332 | + | cmd := &cobra.Command{ | |
| 333 | + | Use: "show APP", | |
| 334 | + | Short: "Show desired and active state for an app", | |
| 335 | + | Args: cobra.ExactArgs(1), | |
| 336 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 337 | + | handle, err := resolveAppPlane() | |
| 338 | + | if err != nil { | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | defer handle.close() | |
| 342 | + | plane := handle.plane | |
| 343 | + | return runAppsShow(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut) | |
| 344 | + | }, | |
| 345 | + | } | |
| 346 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 347 | + | return cmd | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error { | |
| 351 | + | resp, err := plane.ShowApp(ctx, app) | |
| 352 | + | if err != nil { | |
| 353 | + | return err | |
| 354 | + | } | |
| 355 | + | if jsonOut { | |
| 356 | + | return writeJSON(out, resp) | |
| 357 | + | } | |
| 358 | + | if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil { | |
| 359 | + | return err | |
| 360 | + | } | |
| 361 | + | if err := cliWriteLine(out, cliRenderMeta("desired:", resp.Desired.Revision+" ("+resp.Desired.Status+")")); err != nil { | |
| 362 | + | return err | |
| 363 | + | } | |
| 364 | + | for _, line := range renderActiveServices(resp.Active) { | |
| 365 | + | if err := cliWriteLine(out, line); err != nil { | |
| 366 | + | return err | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if resp.Intent.Stopped { | |
| 370 | + | if err := cliWriteLine(out, cliRenderWarning("stopped intent is set")); err != nil { | |
| 371 | + | return err | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | if resp.LastOp != nil && resp.LastOp.Op != "" { | |
| 375 | + | if err := cliWriteLine(out, cliRenderMeta("last-op:", resp.LastOp.Op+" ("+resp.LastOp.Outcome+")")); err != nil { | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...) | |
| 380 | + | owned = append(owned, resp.Retained.Secrets...) | |
| 381 | + | if len(owned) > 0 { | |
| 382 | + | sort.Strings(owned) | |
| 383 | + | return cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))) | |
| 384 | + | } | |
| 385 | + | return nil | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // renderActiveServices renders per-service effective state, sorted by name. | |
| 389 | + | // Only ids and digests appear — never secret values. | |
| 390 | + | func renderActiveServices(active dto.AppActiveDTO) []string { | |
| 391 | + | names := make([]string, 0, len(active.Services)) | |
| 392 | + | for name := range active.Services { | |
| 393 | + | names = append(names, name) | |
| 394 | + | } | |
| 395 | + | sort.Strings(names) | |
| 396 | + | lines := make([]string, 0, len(names)+1) | |
| 397 | + | converged := "converged" | |
| 398 | + | if !active.Converged { | |
| 399 | + | converged = "diverged" | |
| 400 | + | } | |
| 401 | + | lines = append(lines, cliRenderMeta("active:", converged)) | |
| 402 | + | for _, name := range names { | |
| 403 | + | svc := active.Services[name] | |
| 404 | + | detail := svc.EffectiveRevision | |
| 405 | + | if svc.Container != "" { | |
| 406 | + | detail += " container=" + svc.Container | |
| 407 | + | } | |
| 408 | + | if svc.RestartUnsafe { | |
| 409 | + | detail += " restart_unsafe" | |
| 410 | + | } | |
| 411 | + | lines = append(lines, cliRenderMeta(" "+name+":", detail)) | |
| 412 | + | } | |
| 413 | + | return lines | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // newAppsDiffCmd creates `apps diff`. | |
| 417 | + | func newAppsDiffCmd() *cobra.Command { | |
| 418 | + | var jsonOut bool | |
| 419 | + | cmd := &cobra.Command{ | |
| 420 | + | Use: "diff APP", | |
| 421 | + | Short: "Show the normalized desired-vs-active diff", | |
| 422 | + | Args: cobra.ExactArgs(1), | |
| 423 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 424 | + | handle, err := resolveAppPlane() | |
| 425 | + | if err != nil { | |
| 426 | + | return err | |
| 427 | + | } | |
| 428 | + | defer handle.close() | |
| 429 | + | plane := handle.plane | |
| 430 | + | return runAppsDiff(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut) | |
| 431 | + | }, | |
| 432 | + | } | |
| 433 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 434 | + | return cmd | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func runAppsDiff(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error { | |
| 438 | + | resp, err := plane.DiffApp(ctx, app) | |
| 439 | + | if err != nil { | |
| 440 | + | return err | |
| 441 | + | } | |
| 442 | + | if jsonOut { | |
| 443 | + | return writeJSON(out, resp) | |
| 444 | + | } | |
| 445 | + | if len(resp.Diff.Added) == 0 && len(resp.Diff.Removed) == 0 && len(resp.Diff.Changed) == 0 { | |
| 446 | + | return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No differences for %s.", app))) | |
| 447 | + | } | |
| 448 | + | for _, section := range []struct { | |
| 449 | + | title string | |
| 450 | + | paths []string | |
| 451 | + | }{ | |
| 452 | + | {"added:", resp.Diff.Added}, | |
| 453 | + | {"removed:", resp.Diff.Removed}, | |
| 454 | + | {"changed:", resp.Diff.Changed}, | |
| 455 | + | } { | |
| 456 | + | paths := append([]string(nil), section.paths...) | |
| 457 | + | sort.Strings(paths) | |
| 458 | + | for _, p := range paths { | |
| 459 | + | if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil { | |
| 460 | + | return err | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // newAppsSecretsCmd creates the `apps secrets` parent command. | |
| 468 | + | func newAppsSecretsCmd() *cobra.Command { | |
| 469 | + | cmd := &cobra.Command{ | |
| 470 | + | Use: "secrets", | |
| 471 | + | Short: "Manage app secret values", | |
| 472 | + | Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history), | |
| 473 | + | --stdin (preferred), or an interactive prompt. Names must already exist in | |
| 474 | + | desired or active state. Only key names are ever echoed back — never values.`, | |
| 475 | + | } | |
| 476 | + | cmd.AddCommand(newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd()) | |
| 477 | + | return cmd | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // newAppsSecretsSetCmd creates `apps secrets set`. | |
| 481 | + | func newAppsSecretsSetCmd() *cobra.Command { | |
| 482 | + | var service string | |
| 483 | + | var fromStdin bool | |
| 484 | + | var jsonOut bool | |
| 485 | + | cmd := &cobra.Command{ | |
| 486 | + | Use: "set APP KEY=VALUE…", | |
| 487 | + | Short: "Write app secret values", | |
| 488 | + | Args: cobra.MinimumNArgs(0), | |
| 489 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 490 | + | handle, err := resolveAppPlane() | |
| 491 | + | if err != nil { | |
| 492 | + | return err | |
| 493 | + | } | |
| 494 | + | defer handle.close() | |
| 495 | + | plane := handle.plane | |
| 496 | + | if len(args) == 0 { | |
| 497 | + | return fmt.Errorf("missing APP argument") | |
| 498 | + | } | |
| 499 | + | return runAppsSecretsSet(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), args[0], args[1:], service, fromStdin, jsonOut) | |
| 500 | + | }, | |
| 501 | + | } | |
| 502 | + | cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)") | |
| 503 | + | cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines from stdin") | |
| 504 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 505 | + | return cmd | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error { | |
| 509 | + | if app == "" { | |
| 510 | + | return fmt.Errorf("missing APP argument") | |
| 511 | + | } | |
| 512 | + | if service == "" { | |
| 513 | + | return fmt.Errorf("missing required flag --service: secrets are service-scoped") | |
| 514 | + | } | |
| 515 | + | if fromStdin { | |
| 516 | + | stdinPairs, err := readSecretStdin(stdin) | |
| 517 | + | if err != nil { | |
| 518 | + | return err | |
| 519 | + | } | |
| 520 | + | pairs = append(pairs, stdinPairs...) | |
| 521 | + | } | |
| 522 | + | values, err := parseSecretPairs(pairs) | |
| 523 | + | if err != nil { | |
| 524 | + | return err | |
| 525 | + | } | |
| 526 | + | if len(values) == 0 { | |
| 527 | + | return fmt.Errorf("no secrets provided: pass KEY=VALUE arguments or --stdin") | |
| 528 | + | } | |
| 529 | + | if err := plane.SetAppSecrets(ctx, app, dto.AppSecretSetRequest{Service: service, Secrets: values}); err != nil { | |
| 530 | + | return err | |
| 531 | + | } | |
| 532 | + | keys := make([]string, 0, len(values)) | |
| 533 | + | for k := range values { | |
| 534 | + | keys = append(keys, k) | |
| 535 | + | } | |
| 536 | + | sort.Strings(keys) | |
| 537 | + | if jsonOut { | |
| 538 | + | return writeJSON(out, map[string]any{"app": app, "service": service, "keys": keys}) | |
| 539 | + | } | |
| 540 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Set %d secret(s) for %s/%s: %s", len(keys), app, service, strings.Join(keys, ", ")))) | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | // newAppsSecretsDeleteCmd creates `apps secrets delete`. | |
| 544 | + | func newAppsSecretsDeleteCmd() *cobra.Command { | |
| 545 | + | var service string | |
| 546 | + | var jsonOut bool | |
| 547 | + | cmd := &cobra.Command{ | |
| 548 | + | Use: "delete APP KEY", | |
| 549 | + | Short: "Delete an app secret value", | |
| 550 | + | Args: cobra.ExactArgs(2), | |
| 551 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 552 | + | handle, err := resolveAppPlane() | |
| 553 | + | if err != nil { | |
| 554 | + | return err | |
| 555 | + | } | |
| 556 | + | defer handle.close() | |
| 557 | + | plane := handle.plane | |
| 558 | + | return runAppsSecretsDelete(cmd.Context(), plane, cmd.OutOrStdout(), args[0], args[1], service, jsonOut) | |
| 559 | + | }, | |
| 560 | + | } | |
| 561 | + | cmd.Flags().StringVar(&service, "service", "", "Service the secret belongs to (required)") | |
| 562 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 563 | + | return cmd | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer, app, key, service string, jsonOut bool) error { | |
| 567 | + | if service == "" { | |
| 568 | + | return fmt.Errorf("missing required flag --service: secrets are service-scoped") | |
| 569 | + | } | |
| 570 | + | if key == "" { | |
| 571 | + | return fmt.Errorf("missing KEY argument") | |
| 572 | + | } | |
| 573 | + | if err := plane.DeleteAppSecret(ctx, app, dto.AppSecretDeleteRequest{Service: service, Key: key}); err != nil { | |
| 574 | + | return err | |
| 575 | + | } | |
| 576 | + | if jsonOut { | |
| 577 | + | return writeJSON(out, map[string]any{"app": app, "service": service, "deleted": key}) | |
| 578 | + | } | |
| 579 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service))) | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty; | |
| 583 | + | // values may be empty (explicit empty secret). | |
| 584 | + | func parseSecretPairs(pairs []string) (map[string]string, error) { | |
| 585 | + | values := make(map[string]string, len(pairs)) | |
| 586 | + | for _, pair := range pairs { | |
| 587 | + | key, value, ok := strings.Cut(pair, "=") | |
| 588 | + | if !ok || key == "" { | |
| 589 | + | return nil, fmt.Errorf("invalid secret %q: expected KEY=VALUE", pair) | |
| 590 | + | } | |
| 591 | + | values[key] = value | |
| 592 | + | } | |
| 593 | + | return values, nil | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | // readSecretStdin reads KEY=VALUE lines (blank lines ignored). | |
| 597 | + | func readSecretStdin(in io.Reader) ([]string, error) { | |
| 598 | + | data, err := io.ReadAll(in) | |
| 599 | + | if err != nil { | |
| 600 | + | return nil, fmt.Errorf("failed to read secrets from stdin: %w", err) | |
| 601 | + | } | |
| 602 | + | var pairs []string | |
| 603 | + | for _, line := range strings.Split(string(data), "\n") { | |
| 604 | + | if trimmed := strings.TrimSpace(line); trimmed != "" { | |
| 605 | + | pairs = append(pairs, trimmed) | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | return pairs, nil | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // newAppDeployCmd creates `deploy APP`. At cutover this replaces the | |
| 612 | + | // domain-based deploy for apps; domain-based deploy keeps working for | |
| 613 | + | // non-app resources until then (05-api-cli.md §5). | |
| 614 | + | func newAppDeployCmd() *cobra.Command { | |
| 615 | + | var revision string | |
| 616 | + | var service string | |
| 617 | + | var jsonOut bool | |
| 618 | + | cmd := &cobra.Command{ | |
| 619 | + | Use: "deploy APP", | |
| 620 | + | Short: "Activate an app revision", | |
| 621 | + | Args: cobra.ExactArgs(1), | |
| 622 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 623 | + | handle, err := resolveAppPlane() | |
| 624 | + | if err != nil { | |
| 625 | + | return err | |
| 626 | + | } | |
| 627 | + | defer handle.close() | |
| 628 | + | plane := handle.plane | |
| 629 | + | return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), jsonOut) | |
| 630 | + | }, | |
| 631 | + | } | |
| 632 | + | cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)") | |
| 633 | + | cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only") | |
| 634 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 635 | + | return cmd | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out io.Writer, jsonOut bool) error { | |
| 639 | + | resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service}) | |
| 640 | + | if err != nil { | |
| 641 | + | if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok { | |
| 642 | + | return conflictErr | |
| 643 | + | } | |
| 644 | + | return appMutationError("deploy", app, key, err) | |
| 645 | + | } | |
| 646 | + | if jsonOut { | |
| 647 | + | return writeJSON(out, resp) | |
| 648 | + | } | |
| 649 | + | return renderAppDeployResponse(out, resp) | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve. | |
| 653 | + | func newAppRestartCmd() *cobra.Command { | |
| 654 | + | var service string | |
| 655 | + | var jsonOut bool | |
| 656 | + | cmd := &cobra.Command{ | |
| 657 | + | Use: "restart APP", | |
| 658 | + | Short: "Restart an app from pinned digests", | |
| 659 | + | Args: cobra.ExactArgs(1), | |
| 660 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 661 | + | handle, err := resolveAppPlane() | |
| 662 | + | if err != nil { | |
| 663 | + | return err | |
| 664 | + | } | |
| 665 | + | defer handle.close() | |
| 666 | + | plane := handle.plane | |
| 667 | + | return runAppRestart(cmd.Context(), plane, args[0], service, cmd.OutOrStdout(), jsonOut) | |
| 668 | + | }, | |
| 669 | + | } | |
| 670 | + | cmd.Flags().StringVar(&service, "service", "", "Restart a single service only") | |
| 671 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 672 | + | return cmd | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, out io.Writer, jsonOut bool) error { | |
| 676 | + | resp, key, err := plane.RestartApp(ctx, app, service) | |
| 677 | + | if err != nil { | |
| 678 | + | if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok { | |
| 679 | + | return conflictErr | |
| 680 | + | } | |
| 681 | + | return appMutationError("restart", app, key, err) | |
| 682 | + | } | |
| 683 | + | if jsonOut { | |
| 684 | + | return writeJSON(out, resp) | |
| 685 | + | } | |
| 686 | + | return renderAppDeployResponse(out, resp) | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | // newAppStopCmd creates `stop APP`: durable stopped intent, data preserved. | |
| 690 | + | func newAppStopCmd() *cobra.Command { | |
| 691 | + | var jsonOut bool | |
| 692 | + | cmd := &cobra.Command{ | |
| 693 | + | Use: "stop APP", | |
| 694 | + | Short: "Stop an app (preserves all data)", | |
| 695 | + | Args: cobra.ExactArgs(1), | |
| 696 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 697 | + | handle, err := resolveAppPlane() | |
| 698 | + | if err != nil { | |
| 699 | + | return err | |
| 700 | + | } | |
| 701 | + | defer handle.close() | |
| 702 | + | plane := handle.plane | |
| 703 | + | return runAppLifecycle(cmd.Context(), plane.StopApp, "stop", args[0], cmd.OutOrStdout(), jsonOut) | |
| 704 | + | }, | |
| 705 | + | } | |
| 706 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 707 | + | return cmd | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | // newAppStartCmd creates `start APP`: clears intent, ensures running. | |
| 711 | + | func newAppStartCmd() *cobra.Command { | |
| 712 | + | var jsonOut bool | |
| 713 | + | cmd := &cobra.Command{ | |
| 714 | + | Use: "start APP", | |
| 715 | + | Short: "Start a stopped app from active state", | |
| 716 | + | Args: cobra.ExactArgs(1), | |
| 717 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 718 | + | handle, err := resolveAppPlane() | |
| 719 | + | if err != nil { | |
| 720 | + | return err | |
| 721 | + | } | |
| 722 | + | defer handle.close() | |
| 723 | + | plane := handle.plane | |
| 724 | + | return runAppLifecycle(cmd.Context(), plane.StartApp, "start", args[0], cmd.OutOrStdout(), jsonOut) | |
| 725 | + | }, | |
| 726 | + | } | |
| 727 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 728 | + | return cmd | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | // newAppRemoveCmd creates `remove APP`: withdraws workloads, retains data. | |
| 732 | + | func newAppRemoveCmd() *cobra.Command { | |
| 733 | + | var jsonOut bool | |
| 734 | + | cmd := &cobra.Command{ | |
| 735 | + | Use: "remove APP", | |
| 736 | + | Short: "Remove app workloads (volumes and secrets are retained)", | |
| 737 | + | Args: cobra.ExactArgs(1), | |
| 738 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 739 | + | handle, err := resolveAppPlane() | |
| 740 | + | if err != nil { | |
| 741 | + | return err | |
| 742 | + | } | |
| 743 | + | defer handle.close() | |
| 744 | + | plane := handle.plane | |
| 745 | + | return runAppLifecycle(cmd.Context(), plane.RemoveApp, "remove", args[0], cmd.OutOrStdout(), jsonOut) | |
| 746 | + | }, | |
| 747 | + | } | |
| 748 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 749 | + | return cmd | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // appLifecycleFunc is one body-less app lifecycle mutation. | |
| 753 | + | type appLifecycleFunc func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 754 | + | ||
| 755 | + | func runAppLifecycle(ctx context.Context, fn appLifecycleFunc, op, app string, out io.Writer, jsonOut bool) error { | |
| 756 | + | resp, key, err := fn(ctx, app) | |
| 757 | + | if err != nil { | |
| 758 | + | if conflictErr, ok := renderAppOpConflict(out, op, app, key, err, jsonOut); ok { | |
| 759 | + | return conflictErr | |
| 760 | + | } | |
| 761 | + | return appMutationError(op, app, key, err) | |
| 762 | + | } | |
| 763 | + | if jsonOut { | |
| 764 | + | return writeJSON(out, resp) | |
| 765 | + | } | |
| 766 | + | return renderAppDeployResponse(out, resp) | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | // renderAppDeployResponse renders the deploy outcome with terminal | |
| 770 | + | // per-service results, cleanup warnings, and the effective/retained | |
| 771 | + | // summaries. Sorts services for stable output. | |
| 772 | + | func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error { | |
| 773 | + | if err := renderDeployHeadline(out, resp); err != nil { | |
| 774 | + | return err | |
| 775 | + | } | |
| 776 | + | if err := renderDeployServices(out, resp); err != nil { | |
| 777 | + | return err | |
| 778 | + | } | |
| 779 | + | return renderDeploySummaries(out, resp) | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error { | |
| 783 | + | headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome) | |
| 784 | + | if resp.Outcome == "success" { | |
| 785 | + | return cliWriteLine(out, cliRenderSuccess(headline)) | |
| 786 | + | } | |
| 787 | + | return cliWriteLine(out, cliRenderWarning(headline)) | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error { | |
| 791 | + | names := make([]string, 0, len(resp.Services)) | |
| 792 | + | for name := range resp.Services { | |
| 793 | + | names = append(names, name) | |
| 794 | + | } | |
| 795 | + | sort.Strings(names) | |
| 796 | + | for _, name := range names { | |
| 797 | + | svc := resp.Services[name] | |
| 798 | + | detail := svc.Result + " " + svc.EffectiveRevision | |
| 799 | + | if svc.RestartUnsafe { | |
| 800 | + | detail += " restart_unsafe" | |
| 801 | + | } | |
| 802 | + | if svc.Error != "" { | |
| 803 | + | detail += ": " + svc.Error | |
| 804 | + | } | |
| 805 | + | if err := cliWriteLine(out, cliRenderMeta(" "+name+":", detail)); err != nil { | |
| 806 | + | return err | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | for _, w := range resp.CleanupWarnings { | |
| 810 | + | if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("cleanup: %s left %s (%s)", w.Service, w.Leftover, w.Detail))); err != nil { | |
| 811 | + | return err | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | return nil | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | func renderDeploySummaries(out io.Writer, resp *dto.AppDeployResponse) error { | |
| 818 | + | if resp.Effective != nil && len(resp.Effective.Services) > 0 { | |
| 819 | + | pairs := make([]string, 0, len(resp.Effective.Services)) | |
| 820 | + | for name, rev := range resp.Effective.Services { | |
| 821 | + | pairs = append(pairs, name+"="+rev) | |
| 822 | + | } | |
| 823 | + | sort.Strings(pairs) | |
| 824 | + | if err := cliWriteLine(out, cliRenderMeta("effective:", strings.Join(pairs, " "))); err != nil { | |
| 825 | + | return err | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | if resp.Retained == nil { | |
| 829 | + | return nil | |
| 830 | + | } | |
| 831 | + | owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...) | |
| 832 | + | owned = append(owned, resp.Retained.Secrets...) | |
| 833 | + | if len(owned) > 0 { | |
| 834 | + | sort.Strings(owned) | |
| 835 | + | if err := cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))); err != nil { | |
| 836 | + | return err | |
| 837 | + | } | |
| 838 | + | } | |
| 839 | + | return nil | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // newAppStatusCmd creates `status APP`: effective vs observed, per service. | |
| 843 | + | func newAppStatusCmd() *cobra.Command { | |
| 844 | + | var jsonOut bool | |
| 845 | + | cmd := &cobra.Command{ | |
| 846 | + | Use: "status APP", | |
| 847 | + | Short: "Show effective vs observed state for an app", | |
| 848 | + | Args: cobra.ExactArgs(1), | |
| 849 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 850 | + | handle, err := resolveAppPlane() | |
| 851 | + | if err != nil { | |
| 852 | + | return err | |
| 853 | + | } | |
| 854 | + | defer handle.close() | |
| 855 | + | plane := handle.plane | |
| 856 | + | return runAppStatus(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut) | |
| 857 | + | }, | |
| 858 | + | } | |
| 859 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 860 | + | return cmd | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | func runAppStatus(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error { | |
| 864 | + | resp, err := plane.ShowApp(ctx, app) | |
| 865 | + | if err != nil { | |
| 866 | + | return err | |
| 867 | + | } | |
| 868 | + | if jsonOut { | |
| 869 | + | return writeJSON(out, resp) | |
| 870 | + | } | |
| 871 | + | if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil { | |
| 872 | + | return err | |
| 873 | + | } | |
| 874 | + | for _, line := range renderActiveServices(resp.Active) { | |
| 875 | + | if err := cliWriteLine(out, line); err != nil { | |
| 876 | + | return err | |
| 877 | + | } | |
| 878 | + | } | |
| 879 | + | var running []string | |
| 880 | + | for _, svc := range resp.Active.Services { | |
| 881 | + | if svc.Container != "" { | |
| 882 | + | running = append(running, svc.Container) | |
| 883 | + | } | |
| 884 | + | } | |
| 885 | + | sort.Strings(running) | |
| 886 | + | observed := "none" | |
| 887 | + | if len(running) > 0 { | |
| 888 | + | observed = strings.Join(running, " ") | |
| 889 | + | } | |
| 890 | + | if err := cliWriteLine(out, cliRenderMeta("observed:", observed)); err != nil { | |
| 891 | + | return err | |
| 892 | + | } | |
| 893 | + | if resp.Intent.Stopped { | |
| 894 | + | return cliWriteLine(out, cliRenderWarning("stopped intent is set")) | |
| 895 | + | } | |
| 896 | + | return nil | |
| 897 | + | } | |
| 898 | + | ||
| 899 | + | // appLogReader streams logs by Gordon app/service reference. The daemon | |
| 900 | + | // resolves the reference through its authoritative ACTIVE record. | |
| 901 | + | type appLogReader interface { | |
| 902 | + | GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) | |
| 903 | + | StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) | |
| 904 | + | } | |
| 905 | + | ||
| 906 | + | // remoteAppLogReader adapts *remote.Client to appLogReader. | |
| 907 | + | type remoteAppLogReader struct { | |
| 908 | + | client *remote.Client | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | func (r *remoteAppLogReader) GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) { | |
| 912 | + | return r.client.GetContainerLogs(ctx, ref, lines) | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | func (r *remoteAppLogReader) StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) { | |
| 916 | + | return r.client.StreamContainerLogs(ctx, ref, lines) | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | // newAppLogsCmd creates `logs APP`. | |
| 920 | + | func newAppLogsCmd() *cobra.Command { | |
| 921 | + | var service string | |
| 922 | + | var follow bool | |
| 923 | + | var tail int | |
| 924 | + | var jsonOut bool | |
| 925 | + | cmd := &cobra.Command{ | |
| 926 | + | Use: "logs APP", | |
| 927 | + | Short: "Show logs for an app service", | |
| 928 | + | Args: cobra.ExactArgs(1), | |
| 929 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 930 | + | client, err := resolveAppClient() | |
| 931 | + | if err != nil { | |
| 932 | + | return err | |
| 933 | + | } | |
| 934 | + | plane := NewRemoteControlPlane(client) | |
| 935 | + | return runAppLogs(cmd.Context(), plane, &remoteAppLogReader{client: client}, args[0], service, follow, tail, cmd.OutOrStdout(), jsonOut) | |
| 936 | + | }, | |
| 937 | + | } | |
| 938 | + | cmd.Flags().StringVar(&service, "service", "", "Service to read logs from (required when the app has several)") | |
| 939 | + | cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") | |
| 940 | + | cmd.Flags().IntVarP(&tail, "tail", "n", 50, "Number of lines to show") | |
| 941 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 942 | + | return cmd | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | func runAppLogs(ctx context.Context, plane ControlPlane, reader appLogReader, app, service string, follow bool, tail int, out io.Writer, jsonOut bool) error { | |
| 946 | + | if follow && jsonOut { | |
| 947 | + | return fmt.Errorf("cannot combine --json with --follow: follow streams plain log lines") | |
| 948 | + | } | |
| 949 | + | show, err := plane.ShowApp(ctx, app) | |
| 950 | + | if err != nil { | |
| 951 | + | return err | |
| 952 | + | } | |
| 953 | + | ref, err := appLogRef(show, service) | |
| 954 | + | if err != nil { | |
| 955 | + | return err | |
| 956 | + | } | |
| 957 | + | if follow { | |
| 958 | + | stream, err := reader.StreamContainerLogs(ctx, ref, tail) | |
| 959 | + | if err != nil { | |
| 960 | + | return err | |
| 961 | + | } | |
| 962 | + | for line := range stream { | |
| 963 | + | if err := cliWriteLine(out, line); err != nil { | |
| 964 | + | return err | |
| 965 | + | } | |
| 966 | + | } | |
| 967 | + | return nil | |
| 968 | + | } | |
| 969 | + | lines, err := reader.GetContainerLogs(ctx, ref, tail) | |
| 970 | + | if err != nil { | |
| 971 | + | return err | |
| 972 | + | } | |
| 973 | + | if jsonOut { | |
| 974 | + | return writeJSON(out, map[string]any{"app": app, "service": service, "lines": lines}) | |
| 975 | + | } | |
| 976 | + | for _, line := range lines { | |
| 977 | + | if err := cliWriteLine(out, line); err != nil { | |
| 978 | + | return err | |
| 979 | + | } | |
| 980 | + | } | |
| 981 | + | return nil | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | // appLogRef resolves the container ref for one app service. When service | |
| 985 | + | // is empty and the app has exactly one service, that service is used; | |
| 986 | + | // otherwise --service is required. A service with no recorded container | |
| 987 | + | // has nothing to stream yet. | |
| 988 | + | func appLogRef(show *dto.AppShowResponse, service string) (string, error) { | |
| 989 | + | if len(show.Active.Services) == 0 { | |
| 990 | + | return "", fmt.Errorf("app %s has no active services yet", show.App) | |
| 991 | + | } | |
| 992 | + | if service == "" { | |
| 993 | + | if len(show.Active.Services) == 1 { | |
| 994 | + | for name := range show.Active.Services { | |
| 995 | + | service = name | |
| 996 | + | } | |
| 997 | + | } else { | |
| 998 | + | names := make([]string, 0, len(show.Active.Services)) | |
| 999 | + | for name := range show.Active.Services { | |
| 1000 | + | names = append(names, name) | |
| 1001 | + | } | |
| 1002 | + | sort.Strings(names) | |
| 1003 | + | return "", fmt.Errorf("app %s has several services (%s): pass --service", show.App, strings.Join(names, ", ")) | |
| 1004 | + | } | |
| 1005 | + | } | |
| 1006 | + | svc, ok := show.Active.Services[service] | |
| 1007 | + | if !ok { | |
| 1008 | + | return "", fmt.Errorf("app %s has no service %q", show.App, service) | |
| 1009 | + | } | |
| 1010 | + | if svc.Container == "" { | |
| 1011 | + | return "", fmt.Errorf("service %s has no recorded container yet", service) | |
| 1012 | + | } | |
| 1013 | + | return show.App + "/" + service, nil | |
| 1014 | + | } | |
A internal/adapters/in/cli/apps_findings_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestRunAppsApply_DryRunOmitsEmptyRevisionAndRendersDiff(t *testing.T) { | |
| 19 | + | plane := appPlane(t) | |
| 20 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(&dto.AppApplyResponse{ | |
| 21 | + | App: "blog", | |
| 22 | + | Noop: false, | |
| 23 | + | Diff: dto.AppDiffSection{Added: []string{"service.web"}}, | |
| 24 | + | Intent: "apply-7", | |
| 25 | + | }, nil).Once() | |
| 26 | + | var out bytes.Buffer | |
| 27 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | |
| 28 | + | writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false)) | |
| 29 | + | text := out.String() | |
| 30 | + | assert.Contains(t, text, "Validated (dry-run) blog") | |
| 31 | + | assert.NotContains(t, text, "()") | |
| 32 | + | assert.Contains(t, text, "added:") | |
| 33 | + | assert.Contains(t, text, "service.web") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestRunAppsApply_NoopOmitsEmptyRevision(t *testing.T) { | |
| 37 | + | plane := appPlane(t) | |
| 38 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything). | |
| 39 | + | Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once() | |
| 40 | + | var out bytes.Buffer | |
| 41 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | |
| 42 | + | writeManifest(t, "x"), true, false, false)) | |
| 43 | + | assert.Contains(t, out.String(), "No changes for blog") | |
| 44 | + | assert.NotContains(t, out.String(), "()") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestRunAppLogs_RejectsJSONWithFollow(t *testing.T) { | |
| 48 | + | // The JSON/follow rejection happens before any read. | |
| 49 | + | plane := appPlane(t) | |
| 50 | + | err := runAppLogs(context.Background(), plane, &fakeAppLogReader{}, "blog", "web", true, 50, &bytes.Buffer{}, true) | |
| 51 | + | require.Error(t, err) | |
| 52 | + | assert.Contains(t, err.Error(), "--json") | |
| 53 | + | assert.Contains(t, err.Error(), "--follow") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestAppLogRef_ZeroServicesAccurateError(t *testing.T) { | |
| 57 | + | empty := &dto.AppShowResponse{App: "new"} | |
| 58 | + | _, err := appLogRef(empty, "") | |
| 59 | + | require.Error(t, err) | |
| 60 | + | assert.Contains(t, err.Error(), "no active services") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestRunStatusCmd_AppsLabelAndSortedHelpers(t *testing.T) { | |
| 64 | + | plane := climocks.NewMockControlPlane(t) | |
| 65 | + | plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{ | |
| 66 | + | Apps: 2, RegistryDomain: "g.example.com", | |
| 67 | + | ContainerStatus: map[string]string{"zeta": "active", "alpha": "stopped"}, | |
| 68 | + | }, nil).Once() | |
| 69 | + | var out bytes.Buffer | |
| 70 | + | require.NoError(t, runStatusCmd(context.Background(), plane, &out)) | |
| 71 | + | text := out.String() | |
| 72 | + | assert.Contains(t, text, "Apps:") | |
| 73 | + | assert.NotContains(t, text, "Routes:") | |
| 74 | + | assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta")) | |
| 75 | + | } | |
A internal/adapters/in/cli/apps_local_integration_test.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "net/http" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 18 | + | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 19 | + | "github.com/bnema/gordon/internal/adapters/localadmin" | |
| 20 | + | in "github.com/bnema/gordon/internal/boundaries/in" | |
| 21 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 22 | + | "github.com/bnema/gordon/internal/domain" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // validLocalAppManifest is a minimal manifest accepted by the admin apply | |
| 26 | + | // surface (one service, one HTTP host). | |
| 27 | + | const validLocalAppManifest = ` | |
| 28 | + | name = "blog" | |
| 29 | + | [[service]] | |
| 30 | + | name = "web" | |
| 31 | + | image = "img:1" | |
| 32 | + | [[service.http]] | |
| 33 | + | host = "blog.example.com" | |
| 34 | + | port = 8080 | |
| 35 | + | ` | |
| 36 | + | ||
| 37 | + | // TestSharedCommandLocalAdminComposition_Status executes a shared command | |
| 38 | + | // through socket discovery, the Unix transport, and the local authority. | |
| 39 | + | func TestSharedCommandLocalAdminComposition_Status(t *testing.T) { | |
| 40 | + | xdg := t.TempDir() | |
| 41 | + | t.Setenv("XDG_RUNTIME_DIR", xdg) | |
| 42 | + | withRemoteTarget(t, "") | |
| 43 | + | ||
| 44 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 45 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.test").Once() | |
| 46 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Once() | |
| 47 | + | configSvc.EXPECT().GetServerPort().Return(8080).Once() | |
| 48 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true).Once() | |
| 49 | + | appSvc := inmocks.NewMockAppService(t) | |
| 50 | + | appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{App: "blog", Active: "rev-1", Converged: true}}, nil).Once() | |
| 51 | + | ||
| 52 | + | handler := adminhttp.NewHandler(adminhttp.HandlerDeps{ConfigSvc: configSvc, AppSvc: appSvc, Log: zerowrap.Default()}) | |
| 53 | + | shutdown := startLocalAuthority(t, filepath.Join(xdg, "gordon"), handler.LocalAuthority()) | |
| 54 | + | defer shutdown() | |
| 55 | + | stubLocalAppClient(t, remote.NewLocalClient) | |
| 56 | + | ||
| 57 | + | cmd := newStatusCmd() | |
| 58 | + | var out bytes.Buffer | |
| 59 | + | cmd.SetOut(&out) | |
| 60 | + | require.NoError(t, cmd.ExecuteContext(context.Background())) | |
| 61 | + | assert.Contains(t, out.String(), "registry.example.test") | |
| 62 | + | assert.Contains(t, out.String(), "blog") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestAppLocalAdminComposition_EndToEnd composes the real local authority, | |
| 66 | + | // Unix listener, discovery, transport, and app control plane. | |
| 67 | + | func TestAppLocalAdminComposition_EndToEnd(t *testing.T) { | |
| 68 | + | xdg := t.TempDir() | |
| 69 | + | t.Setenv("XDG_RUNTIME_DIR", xdg) | |
| 70 | + | withRemoteTarget(t, "") | |
| 71 | + | ||
| 72 | + | appSvc := inmocks.NewMockAppService(t) | |
| 73 | + | appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return( | |
| 74 | + | &in.AppApplyResult{App: "blog", ResultingRevision: "rev-2", Pending: true, IntentID: "apply-1"}, | |
| 75 | + | nil, nil, | |
| 76 | + | ).Once() | |
| 77 | + | appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{ | |
| 78 | + | {App: "blog", Desired: "rev-2", Active: "rev-1"}, | |
| 79 | + | }, nil).Once() | |
| 80 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{ | |
| 81 | + | App: "blog", | |
| 82 | + | DesiredRevision: "rev-2", | |
| 83 | + | DesiredStatus: "pending", | |
| 84 | + | Services: map[string]in.AppServiceView{"web": {Container: "ctr-web"}}, | |
| 85 | + | }, nil).Once() | |
| 86 | + | appSvc.EXPECT().Diff(mock.Anything, "blog").Return(domain.AppDiff{}, nil).Once() | |
| 87 | + | appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&domain.AppOperation{ | |
| 88 | + | Op: "apply", | |
| 89 | + | App: "blog", | |
| 90 | + | Outcome: "succeeded", | |
| 91 | + | }, nil).Once() | |
| 92 | + | ||
| 93 | + | handler := adminhttp.NewHandler(adminhttp.HandlerDeps{AppSvc: appSvc, Log: zerowrap.Default()}) | |
| 94 | + | dir := filepath.Join(xdg, "gordon") | |
| 95 | + | shutdown := startLocalAuthority(t, dir, handler.LocalAuthority()) | |
| 96 | + | ||
| 97 | + | // The CLI resolves the socket through real discovery and the Unix transport. | |
| 98 | + | stubLocalAppClient(t, remote.NewLocalClient) | |
| 99 | + | ||
| 100 | + | handle, err := resolveAppPlane() | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | defer handle.close() | |
| 103 | + | plane := handle.plane | |
| 104 | + | ||
| 105 | + | ctx := context.Background() | |
| 106 | + | ||
| 107 | + | apply, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: validLocalAppManifest}) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | assert.Equal(t, "rev-2", apply.ResultingRevision) | |
| 110 | + | ||
| 111 | + | apps, err := plane.ListApps(ctx) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | require.Len(t, apps, 1) | |
| 114 | + | assert.Equal(t, "blog", apps[0].App) | |
| 115 | + | ||
| 116 | + | show, err := plane.ShowApp(ctx, "blog") | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Equal(t, "ctr-web", show.Active.Services["web"].Container) | |
| 119 | + | ||
| 120 | + | diff, err := plane.DiffApp(ctx, "blog") | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Equal(t, "blog", diff.App) | |
| 123 | + | ||
| 124 | + | // Idempotent-mutation recovery path: re-query the same key. | |
| 125 | + | op, err := plane.OperationByKey(ctx, "blog", "key-1") | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | assert.Equal(t, "succeeded", op.Outcome) | |
| 128 | + | ||
| 129 | + | // Shutdown removes only the daemon-owned socket; restart recreates it. | |
| 130 | + | shutdown() | |
| 131 | + | assert.NoFileExists(t, localadmin.SocketPath(dir)) | |
| 132 | + | ||
| 133 | + | restartShutdown := startLocalAuthority(t, dir, handler.LocalAuthority()) | |
| 134 | + | require.FileExists(t, localadmin.SocketPath(dir)) | |
| 135 | + | restartShutdown() | |
| 136 | + | assert.NoFileExists(t, localadmin.SocketPath(dir)) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // startLocalAuthority serves the given handler on an owner-only Unix socket | |
| 140 | + | // and returns a shutdown function that stops the server and removes the | |
| 141 | + | // socket, mirroring daemon lifecycle. | |
| 142 | + | func startLocalAuthority(t *testing.T, dir string, handler http.Handler) func() { | |
| 143 | + | t.Helper() | |
| 144 | + | ln, info, err := localadmin.Listen(dir) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | ||
| 147 | + | srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} | |
| 148 | + | go func() { _ = srv.Serve(ln) }() | |
| 149 | + | ||
| 150 | + | return func() { | |
| 151 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 152 | + | defer cancel() | |
| 153 | + | _ = srv.Shutdown(ctx) | |
| 154 | + | require.NoError(t, localadmin.RemoveOwnedSocket(localadmin.SocketPath(dir), info)) | |
| 155 | + | } | |
| 156 | + | } | |
A internal/adapters/in/cli/apps_local_test.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 16 | + | "github.com/bnema/gordon/internal/adapters/localadmin" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // appPlaneFixtureHandler serves the same app DTO payloads a daemon would, | |
| 20 | + | // independent of transport, so local and remote seams can be compared. | |
| 21 | + | func appPlaneFixtureHandler(t *testing.T) http.Handler { | |
| 22 | + | t.Helper() | |
| 23 | + | mux := http.NewServeMux() | |
| 24 | + | mux.HandleFunc("/admin/apps", func(w http.ResponseWriter, _ *http.Request) { | |
| 25 | + | writeFixtureJSON(t, w, map[string]any{ | |
| 26 | + | "apps": []dto.AppSummaryDTO{{App: "blog", Desired: "rev-2", Active: "rev-1", Converged: false}}, | |
| 27 | + | }) | |
| 28 | + | }) | |
| 29 | + | mux.HandleFunc("/admin/apps/blog", func(w http.ResponseWriter, _ *http.Request) { | |
| 30 | + | writeFixtureJSON(t, w, dto.AppShowResponse{ | |
| 31 | + | App: "blog", | |
| 32 | + | Desired: dto.AppDesiredDTO{Revision: "rev-2", Status: "pending"}, | |
| 33 | + | Active: dto.AppActiveDTO{ | |
| 34 | + | Converged: true, | |
| 35 | + | Services: map[string]dto.AppActiveServiceDTO{ | |
| 36 | + | "web": {EffectiveRevision: "rev-1", Container: "ctr-web"}, | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | }) | |
| 40 | + | }) | |
| 41 | + | mux.HandleFunc("/admin/apps/blog/diff", func(w http.ResponseWriter, _ *http.Request) { | |
| 42 | + | writeFixtureJSON(t, w, dto.AppDiffResponse{App: "blog"}) | |
| 43 | + | }) | |
| 44 | + | mux.HandleFunc("/admin/apps/apply", func(w http.ResponseWriter, r *http.Request) { | |
| 45 | + | if r.Method != http.MethodPost { | |
| 46 | + | w.WriteHeader(http.StatusMethodNotAllowed) | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | writeFixtureJSON(t, w, dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-3"}) | |
| 50 | + | }) | |
| 51 | + | return mux | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func writeFixtureJSON(t *testing.T, w http.ResponseWriter, payload any) { | |
| 55 | + | t.Helper() | |
| 56 | + | w.Header().Set("Content-Type", "application/json") | |
| 57 | + | require.NoError(t, json.NewEncoder(w).Encode(payload)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // startUnixAppPlane serves the fixture over an owner-only Unix socket. | |
| 61 | + | func startUnixAppPlane(t *testing.T) string { | |
| 62 | + | t.Helper() | |
| 63 | + | dir := t.TempDir() | |
| 64 | + | ln, _, err := localadmin.Listen(dir) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | srv := &http.Server{Handler: appPlaneFixtureHandler(t), ReadHeaderTimeout: 5 * time.Second} | |
| 68 | + | go func() { _ = srv.Serve(ln) }() | |
| 69 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 70 | + | ||
| 71 | + | return localadmin.SocketPath(dir) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // withRemoteTarget pins the CLI remote flags and clears the environment. | |
| 75 | + | func withRemoteTarget(t *testing.T, target string) { | |
| 76 | + | t.Helper() | |
| 77 | + | origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag | |
| 78 | + | t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure }) | |
| 79 | + | remoteFlag, tokenFlag, insecureTLSFlag = target, "", false | |
| 80 | + | ||
| 81 | + | t.Setenv("GORDON_REMOTE", "") | |
| 82 | + | t.Setenv("GORDON_TOKEN", "") | |
| 83 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func stubLocalAppClient(t *testing.T, fn func() (*remote.Client, error)) { | |
| 87 | + | t.Helper() | |
| 88 | + | restore := newLocalAppClient | |
| 89 | + | newLocalAppClient = fn | |
| 90 | + | t.Cleanup(func() { newLocalAppClient = restore }) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestResolveAppPlane_UsesLocalAdminSocket(t *testing.T) { | |
| 94 | + | socketPath := startUnixAppPlane(t) | |
| 95 | + | withRemoteTarget(t, "") | |
| 96 | + | stubLocalAppClient(t, func() (*remote.Client, error) { | |
| 97 | + | return remote.NewLocalClientForSocket(socketPath), nil | |
| 98 | + | }) | |
| 99 | + | ||
| 100 | + | handle, err := resolveAppPlane() | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | defer handle.close() | |
| 103 | + | plane := handle.plane | |
| 104 | + | ||
| 105 | + | apps, err := plane.ListApps(context.Background()) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Len(t, apps, 1) | |
| 108 | + | assert.Equal(t, "blog", apps[0].App) | |
| 109 | + | ||
| 110 | + | show, err := plane.ShowApp(context.Background(), "blog") | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | assert.Equal(t, "rev-2", show.Desired.Revision) | |
| 113 | + | ||
| 114 | + | diff, err := plane.DiffApp(context.Background(), "blog") | |
| 115 | + | require.NoError(t, err) | |
| 116 | + | assert.Equal(t, "blog", diff.App) | |
| 117 | + | ||
| 118 | + | apply, err := plane.ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "name = \"blog\"\n"}) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | assert.Equal(t, "rev-3", apply.ResultingRevision) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // TestResolveAppControlPlane_LocalAndRemoteDTOParity asserts the shared DTO | |
| 124 | + | // seam yields identical values over both transports. | |
| 125 | + | func TestControlPlane_LocalAndRemoteDTOParity(t *testing.T) { | |
| 126 | + | fixture := appPlaneFixtureHandler(t) | |
| 127 | + | ||
| 128 | + | tcp := httptest.NewServer(fixture) | |
| 129 | + | t.Cleanup(tcp.Close) | |
| 130 | + | ||
| 131 | + | dir := t.TempDir() | |
| 132 | + | ln, _, err := localadmin.Listen(dir) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | unixSrv := &http.Server{Handler: fixture, ReadHeaderTimeout: 5 * time.Second} | |
| 135 | + | go func() { _ = unixSrv.Serve(ln) }() | |
| 136 | + | t.Cleanup(func() { _ = unixSrv.Close() }) | |
| 137 | + | ||
| 138 | + | localPlane := NewRemoteControlPlane(remote.NewLocalClientForSocket(localadmin.SocketPath(dir))) | |
| 139 | + | remotePlane := NewRemoteControlPlane(remote.NewClient(tcp.URL)) | |
| 140 | + | ||
| 141 | + | ctx := context.Background() | |
| 142 | + | ||
| 143 | + | localApps, err := localPlane.ListApps(ctx) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | remoteApps, err := remotePlane.ListApps(ctx) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | assert.Equal(t, remoteApps, localApps) | |
| 148 | + | ||
| 149 | + | localShow, err := localPlane.ShowApp(ctx, "blog") | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | remoteShow, err := remotePlane.ShowApp(ctx, "blog") | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | assert.Equal(t, remoteShow, localShow) | |
| 154 | + | ||
| 155 | + | localDiff, err := localPlane.DiffApp(ctx, "blog") | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | remoteDiff, err := remotePlane.DiffApp(ctx, "blog") | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | assert.Equal(t, remoteDiff, localDiff) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // TestResolveAppControlPlane_ExplicitRemoteNeverProbesLocal pins that an | |
| 163 | + | // explicit remote is authoritative: the local socket is never consulted, and | |
| 164 | + | // a remote failure never falls back to it. | |
| 165 | + | func TestResolveAppPlane_ExplicitRemoteNeverProbesLocal(t *testing.T) { | |
| 166 | + | t.Run("remote succeeds", func(t *testing.T) { | |
| 167 | + | tcp := httptest.NewServer(appPlaneFixtureHandler(t)) | |
| 168 | + | t.Cleanup(tcp.Close) | |
| 169 | + | withRemoteTarget(t, tcp.URL) | |
| 170 | + | ||
| 171 | + | probed := false | |
| 172 | + | stubLocalAppClient(t, func() (*remote.Client, error) { | |
| 173 | + | probed = true | |
| 174 | + | return nil, remote.ErrDaemonUnavailable | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | handle, err := resolveAppPlane() | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | defer handle.close() | |
| 180 | + | plane := handle.plane | |
| 181 | + | ||
| 182 | + | apps, err := plane.ListApps(context.Background()) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | require.Len(t, apps, 1) | |
| 185 | + | assert.False(t, probed, "local socket must not be probed when a remote is selected") | |
| 186 | + | }) | |
| 187 | + | ||
| 188 | + | t.Run("remote fails without local fallback", func(t *testing.T) { | |
| 189 | + | tcp := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) | |
| 190 | + | url := tcp.URL | |
| 191 | + | tcp.Close() | |
| 192 | + | ||
| 193 | + | withRemoteTarget(t, url) | |
| 194 | + | ||
| 195 | + | probed := false | |
| 196 | + | stubLocalAppClient(t, func() (*remote.Client, error) { | |
| 197 | + | probed = true | |
| 198 | + | return remote.NewLocalClientForSocket("/nonexistent/admin.sock"), nil | |
| 199 | + | }) | |
| 200 | + | ||
| 201 | + | handle, err := resolveAppPlane() | |
| 202 | + | require.NoError(t, err, "client construction succeeds; the failure must surface on the request") | |
| 203 | + | defer handle.close() | |
| 204 | + | plane := handle.plane | |
| 205 | + | ||
| 206 | + | _, err = plane.ListApps(context.Background()) | |
| 207 | + | require.Error(t, err) | |
| 208 | + | assert.False(t, probed, "a failed remote must never fall back to the local socket") | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestResolveAppPlane_ReportsDaemonUnavailable(t *testing.T) { | |
| 213 | + | withRemoteTarget(t, "") | |
| 214 | + | stubLocalAppClient(t, func() (*remote.Client, error) { | |
| 215 | + | return nil, remote.ErrDaemonUnavailable | |
| 216 | + | }) | |
| 217 | + | ||
| 218 | + | _, err := resolveAppPlane() | |
| 219 | + | require.Error(t, err) | |
| 220 | + | assert.ErrorContains(t, err, "daemon-unavailable") | |
| 221 | + | assert.ErrorIs(t, err, remote.ErrDaemonUnavailable) | |
| 222 | + | } | |
A internal/adapters/in/cli/apps_test.go +531 -0
| @@ -0,0 +1,531 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | // Tests for the app CLI surface (apps.go). The commands go through one | |
| 4 | + | // ControlPlane served by the generated mock: JSON parity (text and --json | |
| 5 | + | // carry equivalent semantics), no secret values in output, plan-contract | |
| 6 | + | // errors, and outcome-unknown guidance. | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "bytes" | |
| 10 | + | "context" | |
| 11 | + | "encoding/json" | |
| 12 | + | "errors" | |
| 13 | + | "net/http" | |
| 14 | + | "os" | |
| 15 | + | "path/filepath" | |
| 16 | + | "strings" | |
| 17 | + | "testing" | |
| 18 | + | ||
| 19 | + | "github.com/spf13/cobra" | |
| 20 | + | "github.com/stretchr/testify/assert" | |
| 21 | + | "github.com/stretchr/testify/mock" | |
| 22 | + | "github.com/stretchr/testify/require" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 25 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 26 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // appPlane returns the generated control plane mock for one test. | |
| 30 | + | func appPlane(t *testing.T) *climocks.MockControlPlane { | |
| 31 | + | t.Helper() | |
| 32 | + | return climocks.NewMockControlPlane(t) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func writeManifest(t *testing.T, content string) string { | |
| 36 | + | t.Helper() | |
| 37 | + | path := filepath.Join(t.TempDir(), "app.toml") | |
| 38 | + | require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) | |
| 39 | + | return path | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) { | |
| 43 | + | plane := appPlane(t) | |
| 44 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "x.toml", true, true, false) | |
| 45 | + | require.Error(t, err) | |
| 46 | + | assert.Contains(t, err.Error(), "--dry-run") | |
| 47 | + | assert.Contains(t, err.Error(), "--deploy") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestRunAppsApply_RequiresFile(t *testing.T) { | |
| 51 | + | plane := appPlane(t) | |
| 52 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "", false, false, false) | |
| 53 | + | require.Error(t, err) | |
| 54 | + | assert.Contains(t, err.Error(), "--file") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRunAppsApply_JSONParity(t *testing.T) { | |
| 58 | + | want := &dto.AppApplyResponse{ | |
| 59 | + | App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b", | |
| 60 | + | Pending: true, Intent: "apply-123", | |
| 61 | + | Diff: dto.AppDiffSection{Added: []string{"service.web"}}, | |
| 62 | + | } | |
| 63 | + | plane := appPlane(t) | |
| 64 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once() | |
| 65 | + | var out bytes.Buffer | |
| 66 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | |
| 67 | + | writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true)) | |
| 68 | + | var got dto.AppApplyResponse | |
| 69 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 70 | + | assert.Equal(t, *want, got) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) { | |
| 74 | + | plane := appPlane(t) | |
| 75 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return( | |
| 76 | + | &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once() | |
| 77 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |
| 78 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | |
| 79 | + | var out bytes.Buffer | |
| 80 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | |
| 81 | + | writeManifest(t, "x"), false, true, false)) | |
| 82 | + | assert.Contains(t, out.String(), "rev-b") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) { | |
| 86 | + | plane := appPlane(t) | |
| 87 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return( | |
| 88 | + | &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once() | |
| 89 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |
| 90 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | |
| 91 | + | var out bytes.Buffer | |
| 92 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | |
| 93 | + | writeManifest(t, "x"), false, true, true)) | |
| 94 | + | var got struct { | |
| 95 | + | Apply dto.AppApplyResponse `json:"apply"` | |
| 96 | + | Deploy dto.AppDeployResponse `json:"deploy"` | |
| 97 | + | } | |
| 98 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 99 | + | assert.Equal(t, "apply-1", got.Apply.Intent) | |
| 100 | + | assert.Equal(t, "op-1", got.Deploy.Op) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestRunAppsList_JSONParityAndSorting(t *testing.T) { | |
| 104 | + | plane := appPlane(t) | |
| 105 | + | plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{ | |
| 106 | + | {App: "zeta", Converged: true}, | |
| 107 | + | {App: "alpha", Stopped: true}, | |
| 108 | + | }, nil) | |
| 109 | + | var out bytes.Buffer | |
| 110 | + | require.NoError(t, runAppsList(context.Background(), plane, &out, true)) | |
| 111 | + | var got []dto.AppSummaryDTO | |
| 112 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 113 | + | require.Len(t, got, 2) | |
| 114 | + | ||
| 115 | + | out.Reset() | |
| 116 | + | require.NoError(t, runAppsList(context.Background(), plane, &out, false)) | |
| 117 | + | text := out.String() | |
| 118 | + | assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted") | |
| 119 | + | assert.Contains(t, text, "stopped") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestRunAppsList_EmptyJSONIsArray(t *testing.T) { | |
| 123 | + | plane := appPlane(t) | |
| 124 | + | plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once() | |
| 125 | + | var out bytes.Buffer | |
| 126 | + | require.NoError(t, runAppsList(context.Background(), plane, &out, true)) | |
| 127 | + | assert.JSONEq(t, `[]`, strings.TrimSpace(out.String())) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestRunAppsShow_JSONParity(t *testing.T) { | |
| 131 | + | want := &dto.AppShowResponse{ | |
| 132 | + | App: "blog", | |
| 133 | + | Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"}, | |
| 134 | + | Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{ | |
| 135 | + | "web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true}, | |
| 136 | + | }}, | |
| 137 | + | Intent: dto.AppIntentDTO{Stopped: true}, | |
| 138 | + | LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"}, | |
| 139 | + | } | |
| 140 | + | plane := appPlane(t) | |
| 141 | + | plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil) | |
| 142 | + | var out bytes.Buffer | |
| 143 | + | require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true)) | |
| 144 | + | var got dto.AppShowResponse | |
| 145 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 146 | + | assert.Equal(t, *want, got) | |
| 147 | + | ||
| 148 | + | out.Reset() | |
| 149 | + | require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false)) | |
| 150 | + | assert.Contains(t, out.String(), "restart_unsafe", "text shows WHY recovery is blocked") | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) { | |
| 154 | + | plane := appPlane(t) | |
| 155 | + | var gotApp string | |
| 156 | + | var gotReq dto.AppSecretSetRequest | |
| 157 | + | plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything). | |
| 158 | + | Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) { | |
| 159 | + | gotApp, gotReq = app, req | |
| 160 | + | }).Return(nil).Once() | |
| 161 | + | var out bytes.Buffer | |
| 162 | + | require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), | |
| 163 | + | &out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false)) | |
| 164 | + | text := out.String() | |
| 165 | + | assert.NotContains(t, text, "s3cr3t-hunter2") | |
| 166 | + | assert.NotContains(t, text, "abc") | |
| 167 | + | assert.Contains(t, text, "password") | |
| 168 | + | assert.Contains(t, text, "token") | |
| 169 | + | assert.Equal(t, "blog", gotApp) | |
| 170 | + | assert.Equal(t, "web", gotReq.Service) | |
| 171 | + | assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestRunAppsSecretsSet_RequiresService(t *testing.T) { | |
| 175 | + | plane := appPlane(t) | |
| 176 | + | err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), | |
| 177 | + | &bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false) | |
| 178 | + | require.Error(t, err) | |
| 179 | + | assert.Contains(t, err.Error(), "--service") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) { | |
| 183 | + | plane := appPlane(t) | |
| 184 | + | var gotReq dto.AppSecretSetRequest | |
| 185 | + | plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything). | |
| 186 | + | Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once() | |
| 187 | + | var out bytes.Buffer | |
| 188 | + | stdin := strings.NewReader("from_stdin=stdin-value\n\nempty_val=\n") | |
| 189 | + | require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin, | |
| 190 | + | &out, "blog", []string{"from_flag=flag-value"}, "web", true, false)) | |
| 191 | + | assert.Equal(t, map[string]string{ | |
| 192 | + | "from_stdin": "stdin-value", | |
| 193 | + | "empty_val": "", | |
| 194 | + | "from_flag": "flag-value", | |
| 195 | + | }, gotReq.Secrets) | |
| 196 | + | ||
| 197 | + | err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""), | |
| 198 | + | &bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false) | |
| 199 | + | require.Error(t, err) | |
| 200 | + | assert.Contains(t, err.Error(), "KEY=VALUE") | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) { | |
| 204 | + | plane := appPlane(t) | |
| 205 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |
| 206 | + | nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once() | |
| 207 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, false) | |
| 208 | + | require.Error(t, err) | |
| 209 | + | assert.Contains(t, err.Error(), "outcome-unknown") | |
| 210 | + | assert.Contains(t, err.Error(), "key-abc") | |
| 211 | + | assert.Contains(t, err.Error(), "by-key") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) { | |
| 215 | + | plane := appPlane(t) | |
| 216 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{ | |
| 217 | + | StatusCode: http.StatusConflict, Status: "409 Conflict", | |
| 218 | + | Response: dto.AppDeployResponse{ | |
| 219 | + | Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", | |
| 220 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 221 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"}, | |
| 222 | + | }, | |
| 223 | + | }, | |
| 224 | + | }).Once() | |
| 225 | + | var out bytes.Buffer | |
| 226 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, false) | |
| 227 | + | require.Error(t, err, "conflict must retain failure semantics") | |
| 228 | + | text := out.String() | |
| 229 | + | assert.Contains(t, text, "web:") | |
| 230 | + | assert.Contains(t, text, "nope") | |
| 231 | + | assert.Contains(t, err.Error(), "failed") | |
| 232 | + | assert.Contains(t, err.Error(), "op-9") | |
| 233 | + | assert.Contains(t, err.Error(), "key-9") | |
| 234 | + | assert.Contains(t, err.Error(), "by-key") | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) { | |
| 238 | + | plane := appPlane(t) | |
| 239 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{ | |
| 240 | + | StatusCode: http.StatusConflict, Status: "409 Conflict", | |
| 241 | + | Response: dto.AppDeployResponse{ | |
| 242 | + | Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", | |
| 243 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 244 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"}, | |
| 245 | + | }, | |
| 246 | + | }, | |
| 247 | + | }).Once() | |
| 248 | + | var out bytes.Buffer | |
| 249 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, true) | |
| 250 | + | require.Error(t, err) | |
| 251 | + | var got dto.AppDeployResponse | |
| 252 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 253 | + | assert.Equal(t, "op-9", got.Op) | |
| 254 | + | assert.Equal(t, "failed", got.Services["web"].Result) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) { | |
| 258 | + | plane := appPlane(t) | |
| 259 | + | plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{ | |
| 260 | + | StatusCode: http.StatusConflict, Status: "409 Conflict", | |
| 261 | + | Response: dto.AppDeployResponse{ | |
| 262 | + | Op: "op-7", App: "blog", Outcome: "partial", | |
| 263 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 264 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"}, | |
| 265 | + | }, | |
| 266 | + | }, | |
| 267 | + | }).Once() | |
| 268 | + | var out bytes.Buffer | |
| 269 | + | err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false) | |
| 270 | + | require.Error(t, err) | |
| 271 | + | assert.Contains(t, out.String(), "busy") | |
| 272 | + | assert.Contains(t, err.Error(), "key-7") | |
| 273 | + | assert.Contains(t, err.Error(), "by-key") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestRenderAppDeployResponse_SortedServices(t *testing.T) { | |
| 277 | + | resp := &dto.AppDeployResponse{ | |
| 278 | + | Op: "op-1", App: "blog", Outcome: "partial", | |
| 279 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 280 | + | "web": {Result: "deployed", EffectiveRevision: "rev-b"}, | |
| 281 | + | "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true}, | |
| 282 | + | }, | |
| 283 | + | CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}}, | |
| 284 | + | Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}}, | |
| 285 | + | Retained: &dto.AppRetainedDTO{Volumes: []string{"blog-db"}}, | |
| 286 | + | } | |
| 287 | + | var out bytes.Buffer | |
| 288 | + | require.NoError(t, renderAppDeployResponse(&out, resp)) | |
| 289 | + | text := out.String() | |
| 290 | + | assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:")) | |
| 291 | + | assert.Contains(t, text, "restart_unsafe") | |
| 292 | + | assert.Contains(t, text, "cleanup:") | |
| 293 | + | assert.Contains(t, text, "blog-db") | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestAppLogRef_Selection(t *testing.T) { | |
| 297 | + | multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ | |
| 298 | + | "web": {Container: "ctr-web"}, | |
| 299 | + | "db": {Container: "ctr-db"}, | |
| 300 | + | }}} | |
| 301 | + | _, err := appLogRef(multi, "") | |
| 302 | + | require.Error(t, err) | |
| 303 | + | assert.Contains(t, err.Error(), "--service") | |
| 304 | + | ||
| 305 | + | ref, err := appLogRef(multi, "db") | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | assert.Equal(t, "blog/db", ref) | |
| 308 | + | ||
| 309 | + | _, err = appLogRef(multi, "missing") | |
| 310 | + | require.Error(t, err) | |
| 311 | + | ||
| 312 | + | single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ | |
| 313 | + | "web": {Container: "ctr-solo"}, | |
| 314 | + | }}} | |
| 315 | + | ref, err = appLogRef(single, "") | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | assert.Equal(t, "solo/web", ref) | |
| 318 | + | ||
| 319 | + | empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ | |
| 320 | + | "web": {}, | |
| 321 | + | }}} | |
| 322 | + | _, err = appLogRef(empty, "web") | |
| 323 | + | require.Error(t, err) | |
| 324 | + | assert.Contains(t, err.Error(), "no recorded container") | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) { | |
| 328 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 329 | + | t.Setenv("GORDON_REMOTE", "") | |
| 330 | + | t.Setenv("GORDON_TOKEN", "") | |
| 331 | + | origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag | |
| 332 | + | t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure }) | |
| 333 | + | remoteFlag, tokenFlag, insecureTLSFlag = "", "", false | |
| 334 | + | ||
| 335 | + | restore := newLocalAppClient | |
| 336 | + | newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable } | |
| 337 | + | t.Cleanup(func() { newLocalAppClient = restore }) | |
| 338 | + | ||
| 339 | + | _, err := resolveAppPlane() | |
| 340 | + | require.Error(t, err) | |
| 341 | + | assert.Contains(t, err.Error(), "daemon-unavailable") | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md §5): | |
| 345 | + | // every command exists with its frozen Use string and flags. The commands | |
| 346 | + | // stay unregistered until cutover; this test is what keeps them referenced. | |
| 347 | + | func TestAppCommandSurface(t *testing.T) { | |
| 348 | + | root := newAppsCmd() | |
| 349 | + | require.Equal(t, "apps", root.Use) | |
| 350 | + | subs := map[string]bool{} | |
| 351 | + | for _, sub := range root.Commands() { | |
| 352 | + | subs[sub.Name()] = true | |
| 353 | + | } | |
| 354 | + | for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} { | |
| 355 | + | assert.True(t, subs[name], "apps subcommand %s", name) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | secrets := newAppsSecretsCmd() | |
| 359 | + | secretSubs := map[string]bool{} | |
| 360 | + | for _, sub := range secrets.Commands() { | |
| 361 | + | secretSubs[sub.Name()] = true | |
| 362 | + | } | |
| 363 | + | assert.True(t, secretSubs["set"]) | |
| 364 | + | assert.True(t, secretSubs["delete"]) | |
| 365 | + | ||
| 366 | + | flagged := map[*cobra.Command][]string{ | |
| 367 | + | newAppsApplyCmd(): {"file", "dry-run", "deploy", "json"}, | |
| 368 | + | newAppsListCmd(): {"json"}, | |
| 369 | + | newAppsShowCmd(): {"json"}, | |
| 370 | + | newAppsDiffCmd(): {"json"}, | |
| 371 | + | newAppsSecretsSetCmd(): {"service", "stdin", "json"}, | |
| 372 | + | newAppsSecretsDeleteCmd(): {"service", "json"}, | |
| 373 | + | newAppDeployCmd(): {"revision", "service", "json"}, | |
| 374 | + | newAppRestartCmd(): {"service", "json"}, | |
| 375 | + | newAppStopCmd(): {"json"}, | |
| 376 | + | newAppStartCmd(): {"json"}, | |
| 377 | + | newAppRemoveCmd(): {"json"}, | |
| 378 | + | newAppStatusCmd(): {"json"}, | |
| 379 | + | newAppLogsCmd(): {"service", "follow", "tail", "json"}, | |
| 380 | + | } | |
| 381 | + | for cmd, flags := range flagged { | |
| 382 | + | for _, flag := range flags { | |
| 383 | + | assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag) | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | assert.Equal(t, "deploy APP", newAppDeployCmd().Use) | |
| 388 | + | assert.Equal(t, "restart APP", newAppRestartCmd().Use) | |
| 389 | + | assert.Equal(t, "stop APP", newAppStopCmd().Use) | |
| 390 | + | assert.Equal(t, "start APP", newAppStartCmd().Use) | |
| 391 | + | assert.Equal(t, "remove APP", newAppRemoveCmd().Use) | |
| 392 | + | assert.Equal(t, "status APP", newAppStatusCmd().Use) | |
| 393 | + | assert.Equal(t, "logs APP", newAppLogsCmd().Use) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestRunAppsDiff_TextAndJSON(t *testing.T) { | |
| 397 | + | diff := &dto.AppDiffResponse{ | |
| 398 | + | App: "blog", | |
| 399 | + | Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}}, | |
| 400 | + | } | |
| 401 | + | plane := appPlane(t) | |
| 402 | + | plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2) | |
| 403 | + | var out bytes.Buffer | |
| 404 | + | require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false)) | |
| 405 | + | text := out.String() | |
| 406 | + | assert.Contains(t, text, "added:") | |
| 407 | + | assert.Contains(t, text, "removed:") | |
| 408 | + | assert.Contains(t, text, "changed:") | |
| 409 | + | ||
| 410 | + | out.Reset() | |
| 411 | + | require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true)) | |
| 412 | + | var got dto.AppDiffResponse | |
| 413 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 414 | + | assert.Equal(t, *diff, got) | |
| 415 | + | ||
| 416 | + | plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once() | |
| 417 | + | out.Reset() | |
| 418 | + | require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false)) | |
| 419 | + | assert.Contains(t, out.String(), "No differences") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func TestRunAppsSecretsDelete(t *testing.T) { | |
| 423 | + | plane := appPlane(t) | |
| 424 | + | plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2) | |
| 425 | + | var out bytes.Buffer | |
| 426 | + | require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false)) | |
| 427 | + | assert.Contains(t, out.String(), "password") | |
| 428 | + | ||
| 429 | + | out.Reset() | |
| 430 | + | require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true)) | |
| 431 | + | var got map[string]string | |
| 432 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 433 | + | assert.Equal(t, "password", got["deleted"]) | |
| 434 | + | ||
| 435 | + | err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false) | |
| 436 | + | require.Error(t, err) | |
| 437 | + | assert.Contains(t, err.Error(), "--service") | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func TestRunAppRestart_AndLifecycle(t *testing.T) { | |
| 441 | + | resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"} | |
| 442 | + | plane := appPlane(t) | |
| 443 | + | plane.EXPECT().RestartApp(mock.Anything, "blog", "").Return(resp, "key-lc", nil).Times(2) | |
| 444 | + | plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 445 | + | plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 446 | + | plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 447 | + | var out bytes.Buffer | |
| 448 | + | require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, false)) | |
| 449 | + | assert.Contains(t, out.String(), "success") | |
| 450 | + | ||
| 451 | + | out.Reset() | |
| 452 | + | require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, true)) | |
| 453 | + | var got dto.AppDeployResponse | |
| 454 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 455 | + | assert.Equal(t, *resp, got) | |
| 456 | + | ||
| 457 | + | for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} { | |
| 458 | + | out.Reset() | |
| 459 | + | require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false)) | |
| 460 | + | assert.Contains(t, out.String(), "op-2") | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | unknown := appPlane(t) | |
| 464 | + | unknown.EXPECT().StopApp(mock.Anything, "blog").Return( | |
| 465 | + | nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once() | |
| 466 | + | err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false) | |
| 467 | + | require.Error(t, err) | |
| 468 | + | assert.Contains(t, err.Error(), "key-x") | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func TestRunAppStatus_Observed(t *testing.T) { | |
| 472 | + | show := &dto.AppShowResponse{ | |
| 473 | + | App: "blog", | |
| 474 | + | Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{ | |
| 475 | + | "web": {EffectiveRevision: "rev-b", Container: "ctr-b"}, | |
| 476 | + | }}, | |
| 477 | + | } | |
| 478 | + | plane := appPlane(t) | |
| 479 | + | plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2) | |
| 480 | + | var out bytes.Buffer | |
| 481 | + | require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false)) | |
| 482 | + | assert.Contains(t, out.String(), "ctr-b") | |
| 483 | + | ||
| 484 | + | out.Reset() | |
| 485 | + | require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true)) | |
| 486 | + | var got dto.AppShowResponse | |
| 487 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 488 | + | assert.Equal(t, *show, got) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // fakeAppLogReader scripts container log reads for app log tests. | |
| 492 | + | type fakeAppLogReader struct { | |
| 493 | + | lines []string | |
| 494 | + | stream []string | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) { | |
| 498 | + | return f.lines, nil | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) { | |
| 502 | + | ch := make(chan string, len(f.stream)) | |
| 503 | + | for _, line := range f.stream { | |
| 504 | + | ch <- line | |
| 505 | + | } | |
| 506 | + | close(ch) | |
| 507 | + | return ch, nil | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | func TestRunAppLogs_TextJSONFollow(t *testing.T) { | |
| 511 | + | show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{ | |
| 512 | + | "web": {Container: "ctr-web"}, | |
| 513 | + | }}} | |
| 514 | + | plane := appPlane(t) | |
| 515 | + | plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3) | |
| 516 | + | reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}} | |
| 517 | + | ||
| 518 | + | var out bytes.Buffer | |
| 519 | + | require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false)) | |
| 520 | + | assert.Equal(t, "l1\nl2\n", out.String()) | |
| 521 | + | ||
| 522 | + | out.Reset() | |
| 523 | + | require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true)) | |
| 524 | + | var got map[string]any | |
| 525 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 526 | + | assert.Equal(t, "blog", got["app"]) | |
| 527 | + | ||
| 528 | + | out.Reset() | |
| 529 | + | require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false)) | |
| 530 | + | assert.Equal(t, "s1\n", out.String()) | |
| 531 | + | } | |
D internal/adapters/in/cli/attachments.go +0 -516
| @@ -1,516 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "sort" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 11 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 12 | - | "github.com/bnema/gordon/internal/domain" | |
| 13 | - | ||
| 14 | - | "github.com/spf13/cobra" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // newAttachmentsCmd creates the attachments command group. | |
| 18 | - | func newAttachmentsCmd() *cobra.Command { | |
| 19 | - | cmd := &cobra.Command{ | |
| 20 | - | Use: "attachments", | |
| 21 | - | Aliases: []string{"attach"}, | |
| 22 | - | Short: "Manage container attachments", | |
| 23 | - | Long: `Manage container attachments (databases, caches, queues) in the configuration. | |
| 24 | - | ||
| 25 | - | Attachments are service dependencies that run alongside your application containers. | |
| 26 | - | They are defined per-domain or per-network-group in the configuration. | |
| 27 | - | ||
| 28 | - | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |
| 29 | - | these commands operate on the remote server. Otherwise, they require access to | |
| 30 | - | the local Gordon configuration.`, | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | cmd.AddCommand(newAttachmentsListCmd()) | |
| 34 | - | cmd.AddCommand(newAttachmentsAddCmd()) | |
| 35 | - | cmd.AddCommand(newAttachmentsRemoveCmd()) | |
| 36 | - | cmd.AddCommand(newAttachmentsOrphansCmd()) | |
| 37 | - | cmd.AddCommand(newAttachmentsPruneCmd()) | |
| 38 | - | cmd.AddCommand(newAttachmentsPushCmd()) | |
| 39 | - | ||
| 40 | - | return cmd | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // newAttachmentsListCmd creates the attachments list command. | |
| 44 | - | func newAttachmentsListCmd() *cobra.Command { | |
| 45 | - | var jsonOut bool | |
| 46 | - | ||
| 47 | - | cmd := &cobra.Command{ | |
| 48 | - | Use: "list [domain-or-group]", | |
| 49 | - | Short: "List configured attachments", | |
| 50 | - | Long: `List all configured attachments, or attachments for a specific domain/group. | |
| 51 | - | ||
| 52 | - | Examples: | |
| 53 | - | gordon attachments list # List all attachments | |
| 54 | - | gordon attachments list app.example.com # List attachments for domain | |
| 55 | - | gordon attachments list backend # List attachments for network group`, | |
| 56 | - | Args: cobra.MaximumNArgs(1), | |
| 57 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 58 | - | ctx := cmd.Context() | |
| 59 | - | target := "" | |
| 60 | - | if len(args) > 0 { | |
| 61 | - | target = args[0] | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | if target != "" { | |
| 65 | - | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 66 | - | if err != nil { | |
| 67 | - | return err | |
| 68 | - | } | |
| 69 | - | defer handle.close() | |
| 70 | - | return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout()) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | client, isRemote, err := GetRemoteClient() | |
| 74 | - | if err != nil { | |
| 75 | - | return err | |
| 76 | - | } | |
| 77 | - | if isRemote { | |
| 78 | - | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | |
| 79 | - | } | |
| 80 | - | return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) | |
| 81 | - | }, | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 85 | - | ||
| 86 | - | return cmd | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error { | |
| 90 | - | if target != "" { | |
| 91 | - | images, err := cp.GetAttachmentsConfig(ctx, target) | |
| 92 | - | if err != nil { | |
| 93 | - | return fmt.Errorf("failed to get attachments: %w", err) | |
| 94 | - | } | |
| 95 | - | if len(images) == 0 { | |
| 96 | - | _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target) | |
| 97 | - | return err | |
| 98 | - | } | |
| 99 | - | if jsonOut { | |
| 100 | - | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 101 | - | } | |
| 102 | - | return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | attachments, err := cp.GetAllAttachmentsConfig(ctx) | |
| 106 | - | if err != nil { | |
| 107 | - | return fmt.Errorf("failed to list attachments: %w", err) | |
| 108 | - | } | |
| 109 | - | if len(attachments) == 0 { | |
| 110 | - | return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured")) | |
| 111 | - | } | |
| 112 | - | if jsonOut { | |
| 113 | - | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 114 | - | } | |
| 115 | - | return renderAllAttachmentsList(out, "Attachments", attachments) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | func renderAttachmentTargetList(out io.Writer, title string, images []string) error { | |
| 119 | - | if err := cliWriteLine(out, cliRenderTitle(title)); err != nil { | |
| 120 | - | return err | |
| 121 | - | } | |
| 122 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 123 | - | return err | |
| 124 | - | } | |
| 125 | - | for _, img := range images { | |
| 126 | - | if err := cliWritef(out, " %s\n", img); err != nil { | |
| 127 | - | return err | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | return nil | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error { | |
| 134 | - | targets := make([]string, 0, len(attachments)) | |
| 135 | - | for t := range attachments { | |
| 136 | - | targets = append(targets, t) | |
| 137 | - | } | |
| 138 | - | sort.Strings(targets) | |
| 139 | - | ||
| 140 | - | if err := cliWriteLine(out, cliRenderTitle(title)); err != nil { | |
| 141 | - | return err | |
| 142 | - | } | |
| 143 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 144 | - | return err | |
| 145 | - | } | |
| 146 | - | for _, t := range targets { | |
| 147 | - | images := attachments[t] | |
| 148 | - | if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil { | |
| 149 | - | return err | |
| 150 | - | } | |
| 151 | - | for _, img := range images { | |
| 152 | - | if err := cliWritef(out, " %s\n", img); err != nil { | |
| 153 | - | return err | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | return nil | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | |
| 161 | - | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | |
| 162 | - | if target != "" { | |
| 163 | - | // List for specific target | |
| 164 | - | images, err := client.GetAttachmentsConfig(ctx, target) | |
| 165 | - | if err != nil { | |
| 166 | - | return fmt.Errorf("failed to get attachments: %w", err) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | if len(images) == 0 { | |
| 170 | - | fmt.Printf("No attachments configured for '%s'\n", target) | |
| 171 | - | return nil | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | if jsonOut { | |
| 175 | - | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |
| 179 | - | fmt.Println() | |
| 180 | - | for _, img := range images { | |
| 181 | - | fmt.Printf(" %s\n", img) | |
| 182 | - | } | |
| 183 | - | return nil | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // List all attachments | |
| 187 | - | attachments, err := client.GetAllAttachmentsConfig(ctx) | |
| 188 | - | if err != nil { | |
| 189 | - | return fmt.Errorf("failed to list attachments: %w", err) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | if len(attachments) == 0 { | |
| 193 | - | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |
| 194 | - | return nil | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | if jsonOut { | |
| 198 | - | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | // Sort targets for consistent output | |
| 202 | - | targets := make([]string, 0, len(attachments)) | |
| 203 | - | for t := range attachments { | |
| 204 | - | targets = append(targets, t) | |
| 205 | - | } | |
| 206 | - | sort.Strings(targets) | |
| 207 | - | ||
| 208 | - | fmt.Println(styles.Theme.Title.Render("Attachments")) | |
| 209 | - | fmt.Println() | |
| 210 | - | ||
| 211 | - | for _, t := range targets { | |
| 212 | - | images := attachments[t] | |
| 213 | - | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |
| 214 | - | for _, img := range images { | |
| 215 | - | fmt.Printf(" %s\n", img) | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | // runAttachmentsListLocal lists attachments from local configuration. | |
| 223 | - | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { | |
| 224 | - | local, err := GetLocalServices(cfgPath) | |
| 225 | - | if err != nil { | |
| 226 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | if target != "" { | |
| 230 | - | // List for specific target | |
| 231 | - | images, err := local.GetConfigService().GetAttachmentsFor(ctx, target) | |
| 232 | - | if err != nil { | |
| 233 | - | return fmt.Errorf("failed to get attachments: %w", err) | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | if len(images) == 0 { | |
| 237 | - | fmt.Printf("No attachments configured for '%s'\n", target) | |
| 238 | - | return nil | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | if jsonOut { | |
| 242 | - | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | |
| 246 | - | fmt.Println() | |
| 247 | - | for _, img := range images { | |
| 248 | - | fmt.Printf(" %s\n", img) | |
| 249 | - | } | |
| 250 | - | return nil | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // List all attachments | |
| 254 | - | attachments := local.GetConfigService().GetAllAttachments(ctx) | |
| 255 | - | ||
| 256 | - | if len(attachments) == 0 { | |
| 257 | - | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |
| 258 | - | return nil | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | if jsonOut { | |
| 262 | - | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // Sort targets for consistent output | |
| 266 | - | targets := make([]string, 0, len(attachments)) | |
| 267 | - | for t := range attachments { | |
| 268 | - | targets = append(targets, t) | |
| 269 | - | } | |
| 270 | - | sort.Strings(targets) | |
| 271 | - | ||
| 272 | - | fmt.Println(styles.Theme.Title.Render("Attachments (local)")) | |
| 273 | - | fmt.Println() | |
| 274 | - | ||
| 275 | - | for _, t := range targets { | |
| 276 | - | images := attachments[t] | |
| 277 | - | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |
| 278 | - | for _, img := range images { | |
| 279 | - | fmt.Printf(" %s\n", img) | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | return nil | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | func newAttachmentsOrphansCmd() *cobra.Command { | |
| 287 | - | var jsonOut bool | |
| 288 | - | cmd := &cobra.Command{ | |
| 289 | - | Use: "orphans", | |
| 290 | - | Short: "List orphaned attachment containers", | |
| 291 | - | Args: cobra.NoArgs, | |
| 292 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 293 | - | handle, err := resolveControlPlane(configPath) | |
| 294 | - | if err != nil { | |
| 295 | - | return err | |
| 296 | - | } | |
| 297 | - | defer handle.close() | |
| 298 | - | lister, ok := handle.plane.(interface { | |
| 299 | - | ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) | |
| 300 | - | }) | |
| 301 | - | if !ok { | |
| 302 | - | return fmt.Errorf("attachment orphan listing is unavailable") | |
| 303 | - | } | |
| 304 | - | attachments, err := lister.ListOrphanedAttachments(cmd.Context()) | |
| 305 | - | if err != nil { | |
| 306 | - | return fmt.Errorf("failed to list orphaned attachments: %w", err) | |
| 307 | - | } | |
| 308 | - | if jsonOut { | |
| 309 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments}) | |
| 310 | - | } | |
| 311 | - | return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments) | |
| 312 | - | }, | |
| 313 | - | } | |
| 314 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 315 | - | return cmd | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | func newAttachmentsPruneCmd() *cobra.Command { | |
| 319 | - | var jsonOut bool | |
| 320 | - | var stop bool | |
| 321 | - | cmd := &cobra.Command{ | |
| 322 | - | Use: "prune", | |
| 323 | - | Short: "Review or stop orphaned attachment containers", | |
| 324 | - | Args: cobra.NoArgs, | |
| 325 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 326 | - | handle, err := resolveControlPlane(configPath) | |
| 327 | - | if err != nil { | |
| 328 | - | return err | |
| 329 | - | } | |
| 330 | - | defer handle.close() | |
| 331 | - | cleaner, ok := handle.plane.(interface { | |
| 332 | - | CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) | |
| 333 | - | }) | |
| 334 | - | if !ok { | |
| 335 | - | return fmt.Errorf("attachment orphan cleanup is unavailable") | |
| 336 | - | } | |
| 337 | - | report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop) | |
| 338 | - | if err != nil { | |
| 339 | - | return fmt.Errorf("failed to cleanup orphaned attachments: %w", err) | |
| 340 | - | } | |
| 341 | - | if jsonOut { | |
| 342 | - | return writeJSON(cmd.OutOrStdout(), report) | |
| 343 | - | } | |
| 344 | - | return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop) | |
| 345 | - | }, | |
| 346 | - | } | |
| 347 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 348 | - | cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes") | |
| 349 | - | return cmd | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error { | |
| 353 | - | if len(attachments) == 0 { | |
| 354 | - | return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found")) | |
| 355 | - | } | |
| 356 | - | if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil { | |
| 357 | - | return err | |
| 358 | - | } | |
| 359 | - | for _, a := range attachments { | |
| 360 | - | label := a.Name | |
| 361 | - | if label == "" { | |
| 362 | - | label = a.ContainerID | |
| 363 | - | } | |
| 364 | - | if a.Image != "" { | |
| 365 | - | label += " -> " + a.Image | |
| 366 | - | } | |
| 367 | - | if err := cliWriteLine(out, " "+label); err != nil { | |
| 368 | - | return err | |
| 369 | - | } | |
| 370 | - | } | |
| 371 | - | return nil | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error { | |
| 375 | - | if report == nil { | |
| 376 | - | return nil | |
| 377 | - | } | |
| 378 | - | if !stop { | |
| 379 | - | if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil { | |
| 380 | - | return err | |
| 381 | - | } | |
| 382 | - | return writeCleanupMessages(out, report.Warnings, report.Hints) | |
| 383 | - | } | |
| 384 | - | if len(report.RemovedContainers) == 0 { | |
| 385 | - | if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil { | |
| 386 | - | return err | |
| 387 | - | } | |
| 388 | - | } else { | |
| 389 | - | if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil { | |
| 390 | - | return err | |
| 391 | - | } | |
| 392 | - | for _, c := range report.RemovedContainers { | |
| 393 | - | label := c.Name | |
| 394 | - | if label == "" { | |
| 395 | - | label = c.ID | |
| 396 | - | } | |
| 397 | - | if err := cliWriteLine(out, " "+label); err != nil { | |
| 398 | - | return err | |
| 399 | - | } | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | for _, failure := range report.PartialFailures { | |
| 403 | - | if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil { | |
| 404 | - | return err | |
| 405 | - | } | |
| 406 | - | } | |
| 407 | - | return writeCleanupMessages(out, report.Warnings, report.Hints) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // newAttachmentsAddCmd creates the attachments add command. | |
| 411 | - | func newAttachmentsAddCmd() *cobra.Command { | |
| 412 | - | cmd := &cobra.Command{ | |
| 413 | - | Use: "add <domain-or-group> <image>", | |
| 414 | - | Short: "Add an attachment", | |
| 415 | - | Long: `Add a container attachment to a domain or network group. | |
| 416 | - | ||
| 417 | - | Note: Attachments require network_isolation.enabled = true in your configuration. | |
| 418 | - | Without network isolation, containers use Docker's default bridge network which | |
| 419 | - | does not provide DNS resolution - your app won't be able to reach attachments | |
| 420 | - | by hostname (e.g., 'postgres:5432'). | |
| 421 | - | ||
| 422 | - | Examples: | |
| 423 | - | gordon attachments add app.example.com postgres:18 | |
| 424 | - | gordon attachments add backend redis:7-alpine | |
| 425 | - | gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`, | |
| 426 | - | Args: cobra.ExactArgs(2), | |
| 427 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 428 | - | ctx := cmd.Context() | |
| 429 | - | target := args[0] | |
| 430 | - | image := args[1] | |
| 431 | - | ||
| 432 | - | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 433 | - | if err != nil { | |
| 434 | - | return err | |
| 435 | - | } | |
| 436 | - | defer handle.close() | |
| 437 | - | ||
| 438 | - | if !handle.isRemote { | |
| 439 | - | local, err := GetLocalServices(configPath) | |
| 440 | - | if err != nil { | |
| 441 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | // Warn if network isolation is disabled | |
| 445 | - | if !local.GetConfigService().IsNetworkIsolationEnabled() { | |
| 446 | - | fmt.Println(styles.RenderWarning("network_isolation.enabled = false")) | |
| 447 | - | fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution.")) | |
| 448 | - | fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname.")) | |
| 449 | - | fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) | |
| 450 | - | fmt.Println() | |
| 451 | - | } | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | if err := handle.plane.AddAttachment(ctx, target, image); err != nil { | |
| 455 | - | return fmt.Errorf("failed to add attachment: %w", err) | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) | |
| 459 | - | return nil | |
| 460 | - | }, | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | return cmd | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | // newAttachmentsRemoveCmd creates the attachments remove command. | |
| 467 | - | func newAttachmentsRemoveCmd() *cobra.Command { | |
| 468 | - | var force bool | |
| 469 | - | ||
| 470 | - | cmd := &cobra.Command{ | |
| 471 | - | Use: "remove <domain-or-group> <image>", | |
| 472 | - | Short: "Remove an attachment", | |
| 473 | - | Long: `Remove a container attachment from a domain or network group. | |
| 474 | - | ||
| 475 | - | Examples: | |
| 476 | - | gordon attachments remove app.example.com postgres:15 | |
| 477 | - | gordon attachments remove backend redis:7-alpine --force`, | |
| 478 | - | Args: cobra.ExactArgs(2), | |
| 479 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 480 | - | ctx := cmd.Context() | |
| 481 | - | target := args[0] | |
| 482 | - | image := args[1] | |
| 483 | - | ||
| 484 | - | // Confirm unless --force | |
| 485 | - | if !force { | |
| 486 | - | confirmed, err := components.RunConfirm( | |
| 487 | - | fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target), | |
| 488 | - | components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."), | |
| 489 | - | ) | |
| 490 | - | if err != nil { | |
| 491 | - | return err | |
| 492 | - | } | |
| 493 | - | if !confirmed { | |
| 494 | - | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |
| 495 | - | return nil | |
| 496 | - | } | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 500 | - | if err != nil { | |
| 501 | - | return err | |
| 502 | - | } | |
| 503 | - | defer handle.close() | |
| 504 | - | if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil { | |
| 505 | - | return fmt.Errorf("failed to remove attachment: %w", err) | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) | |
| 509 | - | return nil | |
| 510 | - | }, | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |
| 514 | - | ||
| 515 | - | return cmd | |
| 516 | - | } | |
D internal/adapters/in/cli/attachments_push.go +0 -221
| @@ -1,221 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "sort" | |
| 8 | - | "strings" | |
| 9 | - | ||
| 10 | - | "github.com/spf13/cobra" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 13 | - | "github.com/bnema/gordon/pkg/validation" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | var ( | |
| 17 | - | resolveControlPlaneFn = resolveControlPlane | |
| 18 | - | determineVersionFn = determineVersion | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // attachmentPushRequest holds all inputs for the attachments push command. | |
| 22 | - | type attachmentPushRequest struct { | |
| 23 | - | ImageArg string | |
| 24 | - | Tag string | |
| 25 | - | Build buildConfig | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func newAttachmentsPushCmd() *cobra.Command { | |
| 29 | - | var ( | |
| 30 | - | build bool | |
| 31 | - | platform string | |
| 32 | - | tag string | |
| 33 | - | dockerfile string | |
| 34 | - | buildArgs []string | |
| 35 | - | ) | |
| 36 | - | ||
| 37 | - | cmd := &cobra.Command{ | |
| 38 | - | Use: "push <image>", | |
| 39 | - | Short: "Build, tag, and push an attachment image", | |
| 40 | - | Args: cobra.ExactArgs(1), | |
| 41 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 42 | - | return runAttachmentsPush(cmd.Context(), attachmentPushRequest{ | |
| 43 | - | ImageArg: args[0], | |
| 44 | - | Tag: tag, | |
| 45 | - | Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs}, | |
| 46 | - | }, cmd.OutOrStdout()) | |
| 47 | - | }, | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |
| 51 | - | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |
| 52 | - | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |
| 53 | - | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)") | |
| 54 | - | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |
| 55 | - | ||
| 56 | - | return cmd | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error { | |
| 60 | - | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |
| 61 | - | if err != nil { | |
| 62 | - | return err | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg) | |
| 66 | - | if err != nil { | |
| 67 | - | return err | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | var handle *controlPlaneHandle | |
| 71 | - | if inferredRemote != nil { | |
| 72 | - | handle = newRemoteControlPlaneHandle(inferredRemote) | |
| 73 | - | if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | } else { | |
| 77 | - | handle, err = resolveControlPlaneFn(configPath) | |
| 78 | - | if err != nil { | |
| 79 | - | return err | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | defer handle.close() | |
| 83 | - | ||
| 84 | - | for _, ba := range req.Build.BuildArgs { | |
| 85 | - | if err := validateBuildArg(ba); err != nil { | |
| 86 | - | return err | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, req.ImageArg) | |
| 91 | - | if err != nil { | |
| 92 | - | return err | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | version, err := resolveVersionWithFn(ctx, req.Tag) | |
| 96 | - | if err != nil { | |
| 97 | - | return err | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | img := imagePush{ | |
| 101 | - | Registry: registry, | |
| 102 | - | ImageName: imageName, | |
| 103 | - | Version: version, | |
| 104 | - | } | |
| 105 | - | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | |
| 106 | - | ||
| 107 | - | var imageOps pushImageOps | |
| 108 | - | if inferredRemote != nil { | |
| 109 | - | imageOps, err = newImageOpsForResolvedRemote(inferredRemote) | |
| 110 | - | } else { | |
| 111 | - | imageOps, err = newImageOpsFn() | |
| 112 | - | } | |
| 113 | - | if err != nil { | |
| 114 | - | return err | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | if err := printAttachmentPushInfo(out, req.ImageArg, targets, img.VersionRef, img.LatestRef, version); err != nil { | |
| 118 | - | return err | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | build := buildConfig{ | |
| 122 | - | Enabled: req.Build.Enabled, | |
| 123 | - | Platform: req.Build.Platform, | |
| 124 | - | Dockerfile: dockerfile, | |
| 125 | - | BuildArgs: req.Build.BuildArgs, | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | if err := performAttachmentPush(ctx, imageOps, build, img); err != nil { | |
| 129 | - | return err | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return cliWriteLine(out, styles.RenderSuccess("Push complete")) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error { | |
| 136 | - | if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil { | |
| 137 | - | return err | |
| 138 | - | } | |
| 139 | - | if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil { | |
| 140 | - | return err | |
| 141 | - | } | |
| 142 | - | if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil { | |
| 143 | - | return err | |
| 144 | - | } | |
| 145 | - | if version != "latest" { | |
| 146 | - | if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil { | |
| 147 | - | return err | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | return nil | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | func performAttachmentPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error { | |
| 154 | - | if build.Enabled { | |
| 155 | - | return buildAndPush(ctx, ops, build, img) | |
| 156 | - | } | |
| 157 | - | return tagAndPush(ctx, ops, img) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) { | |
| 161 | - | imageName = normalizeAttachmentImageName(imageArg) | |
| 162 | - | ||
| 163 | - | targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg) | |
| 164 | - | if err != nil { | |
| 165 | - | return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err) | |
| 166 | - | } | |
| 167 | - | if len(targets) == 0 { | |
| 168 | - | return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | status, err := cp.GetStatus(ctx) | |
| 172 | - | if err != nil { | |
| 173 | - | return "", "", nil, fmt.Errorf("failed to get status: %w", err) | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | return status.RegistryDomain, imageName, targets, nil | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | func normalizeAttachmentImageName(imageArg string) string { | |
| 180 | - | name := imageArg | |
| 181 | - | if idx := strings.Index(name, "@"); idx != -1 { | |
| 182 | - | name = name[:idx] | |
| 183 | - | } | |
| 184 | - | if idx := strings.LastIndex(name, ":"); idx != -1 { | |
| 185 | - | slashIdx := strings.LastIndex(name, "/") | |
| 186 | - | if idx > slashIdx { | |
| 187 | - | name = name[:idx] | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | parts := strings.SplitN(name, "/", 2) | |
| 191 | - | if len(parts) == 2 { | |
| 192 | - | host := parts[0] | |
| 193 | - | if strings.ContainsAny(host, ".:") || host == "localhost" { | |
| 194 | - | return parts[1] | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | return name | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | func formatAttachmentTargets(targets []string) string { | |
| 201 | - | cloned := append([]string(nil), targets...) | |
| 202 | - | sort.Strings(cloned) | |
| 203 | - | return fmt.Sprintf("%v", cloned) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | func resolveVersionWithFn(ctx context.Context, tag string) (string, error) { | |
| 207 | - | version := determineVersionFn(ctx, tag) | |
| 208 | - | if version != "latest" { | |
| 209 | - | if err := validateVersionTag(version); err != nil { | |
| 210 | - | return "", err | |
| 211 | - | } | |
| 212 | - | } | |
| 213 | - | return version, nil | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | func validateVersionTag(version string) error { | |
| 217 | - | if err := validation.ValidateReference(version); err != nil { | |
| 218 | - | return fmt.Errorf("invalid version tag %q: %w", version, err) | |
| 219 | - | } | |
| 220 | - | return nil | |
| 221 | - | } | |
D internal/adapters/in/cli/attachments_push_test.go +0 -145
| @@ -1,145 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | - | "github.com/stretchr/testify/assert" | |
| 11 | - | "github.com/stretchr/testify/mock" | |
| 12 | - | "github.com/stretchr/testify/require" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func TestResolveAttachmentImage_OneTarget(t *testing.T) { | |
| 16 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 17 | - | cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once() | |
| 18 | - | cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once() | |
| 19 | - | ||
| 20 | - | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "postgres") | |
| 21 | - | ||
| 22 | - | require.NoError(t, err) | |
| 23 | - | assert.Equal(t, "registry.example.com", registry) | |
| 24 | - | assert.Equal(t, "postgres", imageName) | |
| 25 | - | assert.Equal(t, []string{"app.example.com"}, targets) | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func TestResolveAttachmentImage_MultipleTargets(t *testing.T) { | |
| 29 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 30 | - | cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "redis").Return([]string{"app.example.com", "backend"}, nil).Once() | |
| 31 | - | cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once() | |
| 32 | - | ||
| 33 | - | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cpMock, "redis") | |
| 34 | - | ||
| 35 | - | require.NoError(t, err) | |
| 36 | - | assert.Equal(t, "registry.example.com", registry) | |
| 37 | - | assert.Equal(t, "redis", imageName) | |
| 38 | - | assert.Equal(t, []string{"app.example.com", "backend"}, targets) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestResolveAttachmentImage_NotConfigured(t *testing.T) { | |
| 42 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 43 | - | cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return(nil, nil).Once() | |
| 44 | - | ||
| 45 | - | _, _, _, err := resolveAttachmentImage(context.Background(), cpMock, "postgres") | |
| 46 | - | ||
| 47 | - | require.Error(t, err) | |
| 48 | - | assert.Contains(t, err.Error(), "not configured as an attachment") | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func TestAttachmentPushCmd_NoDeploy(t *testing.T) { | |
| 52 | - | origResolveControlPlane := resolveControlPlaneFn | |
| 53 | - | origDetermineVersion := determineVersionFn | |
| 54 | - | origNewImageOps := newImageOpsFn | |
| 55 | - | t.Cleanup(func() { | |
| 56 | - | resolveControlPlaneFn = origResolveControlPlane | |
| 57 | - | determineVersionFn = origDetermineVersion | |
| 58 | - | newImageOpsFn = origNewImageOps | |
| 59 | - | }) | |
| 60 | - | ||
| 61 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 62 | - | cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres").Return([]string{"app.example.com"}, nil).Once() | |
| 63 | - | cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once() | |
| 64 | - | ||
| 65 | - | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |
| 66 | - | return &controlPlaneHandle{plane: cpMock}, nil | |
| 67 | - | } | |
| 68 | - | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |
| 69 | - | pushCalled := false | |
| 70 | - | newImageOpsFn = func() (pushImageOps, error) { | |
| 71 | - | m := climocks.NewMockpushImageOps(t) | |
| 72 | - | m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
| 73 | - | m.On("Exists", mock.Anything, mock.Anything).Return(true, nil) | |
| 74 | - | m.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { | |
| 75 | - | pushCalled = true | |
| 76 | - | }).Return(nil) | |
| 77 | - | return m, nil | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | cmd := newAttachmentsPushCmd() | |
| 81 | - | out := new(bytes.Buffer) | |
| 82 | - | cmd.SetOut(out) | |
| 83 | - | cmd.SetErr(out) | |
| 84 | - | cmd.SetArgs([]string{"postgres"}) | |
| 85 | - | ||
| 86 | - | err := cmd.Execute() | |
| 87 | - | ||
| 88 | - | require.NoError(t, err) | |
| 89 | - | assert.True(t, pushCalled) | |
| 90 | - | assert.Contains(t, out.String(), "Push complete") | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func TestAttachmentPushCmd_TaggedImageInputBuildsValidRefs(t *testing.T) { | |
| 94 | - | origResolveControlPlane := resolveControlPlaneFn | |
| 95 | - | origDetermineVersion := determineVersionFn | |
| 96 | - | origNewImageOps := newImageOpsFn | |
| 97 | - | t.Cleanup(func() { | |
| 98 | - | resolveControlPlaneFn = origResolveControlPlane | |
| 99 | - | determineVersionFn = origDetermineVersion | |
| 100 | - | newImageOpsFn = origNewImageOps | |
| 101 | - | }) | |
| 102 | - | ||
| 103 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 104 | - | cpMock.EXPECT().FindAttachmentTargetsByImage(context.Background(), "postgres:18").Return([]string{"app.example.com"}, nil).Once() | |
| 105 | - | cpMock.EXPECT().GetStatus(context.Background()).Return(&remote.Status{RegistryDomain: "registry.example.com"}, nil).Once() | |
| 106 | - | ||
| 107 | - | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |
| 108 | - | return &controlPlaneHandle{plane: cpMock}, nil | |
| 109 | - | } | |
| 110 | - | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |
| 111 | - | ||
| 112 | - | var gotVersionRef string | |
| 113 | - | var gotLatestRef string | |
| 114 | - | newImageOpsFn = func() (pushImageOps, error) { | |
| 115 | - | m := climocks.NewMockpushImageOps(t) | |
| 116 | - | m.On("Tag", mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
| 117 | - | m.On("Exists", mock.Anything, mock.Anything).Return(true, nil) | |
| 118 | - | m.On("Push", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { | |
| 119 | - | ref := args.Get(1).(string) | |
| 120 | - | if gotVersionRef == "" { | |
| 121 | - | gotVersionRef = ref | |
| 122 | - | } else if gotLatestRef == "" { | |
| 123 | - | gotLatestRef = ref | |
| 124 | - | } | |
| 125 | - | }).Return(nil) | |
| 126 | - | return m, nil | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | cmd := newAttachmentsPushCmd() | |
| 130 | - | cmd.SetOut(new(bytes.Buffer)) | |
| 131 | - | cmd.SetErr(new(bytes.Buffer)) | |
| 132 | - | cmd.SetArgs([]string{"postgres:18"}) | |
| 133 | - | ||
| 134 | - | err := cmd.Execute() | |
| 135 | - | ||
| 136 | - | require.NoError(t, err) | |
| 137 | - | assert.Equal(t, "registry.example.com/postgres:v1.2.3", gotVersionRef) | |
| 138 | - | assert.Equal(t, "registry.example.com/postgres:latest", gotLatestRef) | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestNormalizeAttachmentImageName_PreservesNamespaceWithoutRegistryHost(t *testing.T) { | |
| 142 | - | got := normalizeAttachmentImageName("myorg/postgres:18") | |
| 143 | - | ||
| 144 | - | assert.Equal(t, "myorg/postgres", got) | |
| 145 | - | } | |
M internal/adapters/in/cli/auth.go +88 -61
| @@ -60,11 +60,13 @@ func newAuthTokenCmd() *cobra.Command { | |||
| 60 | 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 +1 -37
| @@ -37,7 +37,7 @@ Examples: | |||
| 37 | 37 | gordon config show --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 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 | |
M internal/adapters/in/cli/controlplane.go +21 -36
| @@ -9,35 +9,26 @@ 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 | + | // Both explicit remote and local-daemon implementations call admin HTTP APIs. | |
| 15 | 13 | type ControlPlane interface { | |
| 16 | - | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | |
| 17 | - | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | |
| 18 | - | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | |
| 19 | - | FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) | |
| 20 | - | AddRoute(ctx context.Context, route domain.Route) error | |
| 21 | - | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 22 | - | RemoveRoute(ctx context.Context, routeDomain string) error | |
| 23 | - | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |
| 24 | - | ||
| 25 | - | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 14 | + | // App lifecycle and reads. App mutations are daemon-owned for both | |
| 15 | + | // the explicit remote and the owner-only local admin socket. | |
| 16 | + | ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) | |
| 17 | + | ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) | |
| 18 | + | ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) | |
| 19 | + | DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) | |
| 20 | + | DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) | |
| 21 | + | StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 22 | + | StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 23 | + | RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) | |
| 24 | + | RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 25 | + | OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) | |
| 26 | + | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | |
| 27 | + | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | |
| 28 | + | ||
| 29 | + | ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 26 | 30 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| 27 | 31 | DeleteSecret(ctx context.Context, secretDomain, key string) error | |
| 28 | - | SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error | |
| 29 | - | DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error | |
| 30 | - | ||
| 31 | - | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |
| 32 | - | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 33 | - | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |
| 34 | - | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |
| 35 | - | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |
| 36 | - | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 37 | - | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 38 | - | GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) | |
| 39 | - | AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 40 | - | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 41 | 32 | ||
| 42 | 33 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 43 | 34 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| @@ -45,23 +36,17 @@ type ControlPlane interface { | |||
| 45 | 36 | Reload(ctx context.Context) error | |
| 46 | 37 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 47 | 38 | GetConfig(ctx context.Context) (*remote.Config, error) | |
| 48 | - | DeployIntent(ctx context.Context, imageName string) error | |
| 49 | - | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 50 | - | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | |
| 51 | 39 | ListTags(ctx context.Context, repository string) ([]string, error) | |
| 52 | 40 | ||
| 53 | - | ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) | |
| 41 | + | ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) | |
| 54 | 42 | BackupStatus(ctx context.Context) ([]dto.BackupJob, error) | |
| 55 | - | RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) | |
| 56 | - | DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) | |
| 57 | - | ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) | |
| 43 | + | RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) | |
| 44 | + | ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) | |
| 58 | 45 | VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) | |
| 59 | - | RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) | |
| 46 | + | RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) | |
| 60 | 47 | ||
| 61 | 48 | GetProcessLogs(ctx context.Context, lines int) ([]string, error) | |
| 62 | - | GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) | |
| 63 | 49 | StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) | |
| 64 | - | StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) | |
| 65 | 50 | ||
| 66 | 51 | ListVolumes(ctx context.Context) ([]dto.Volume, error) | |
| 67 | 52 | PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) | |
D internal/adapters/in/cli/controlplane_local.go +0 -872
| @@ -1,872 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "sort" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 12 | - | "github.com/bnema/gordon/internal/app" | |
| 13 | - | "github.com/bnema/gordon/internal/boundaries/in" | |
| 14 | - | "github.com/bnema/gordon/internal/domain" | |
| 15 | - | "github.com/bnema/gordon/internal/usecase/config" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | type localControlPlane struct { | |
| 19 | - | configSvc in.ConfigService | |
| 20 | - | secretSvc in.SecretService | |
| 21 | - | containerSvc in.ContainerService | |
| 22 | - | backupSvc in.BackupService | |
| 23 | - | volumeBackupSvc in.VolumeBackupService | |
| 24 | - | registrySvc in.RegistryService | |
| 25 | - | deployCoord in.DeployCoordinator | |
| 26 | - | healthSvc in.HealthService | |
| 27 | - | logSvc in.LogService | |
| 28 | - | volumeSvc in.VolumeService | |
| 29 | - | publicTLSSvc in.PublicTLSService | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| 33 | - | if kernel == nil { | |
| 34 | - | return &localControlPlane{} | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | registrySvc := kernel.Registry() | |
| 38 | - | var deployCoord in.DeployCoordinator | |
| 39 | - | if registrySvc != nil { | |
| 40 | - | if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok { | |
| 41 | - | deployCoord = coordinator | |
| 42 | - | } | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return &localControlPlane{ | |
| 46 | - | configSvc: kernel.Config(), | |
| 47 | - | secretSvc: kernel.Secrets(), | |
| 48 | - | containerSvc: kernel.Container(), | |
| 49 | - | backupSvc: kernel.Backup(), | |
| 50 | - | volumeBackupSvc: kernel.VolumeBackup(), | |
| 51 | - | registrySvc: registrySvc, | |
| 52 | - | deployCoord: deployCoord, | |
| 53 | - | healthSvc: kernel.Health(), | |
| 54 | - | logSvc: kernel.Logs(), | |
| 55 | - | volumeSvc: kernel.Volumes(), | |
| 56 | - | publicTLSSvc: kernel.PublicTLS(), | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 61 | - | if l.containerSvc != nil { | |
| 62 | - | if err := l.containerSvc.SyncContainers(ctx); err != nil { | |
| 63 | - | return nil, err | |
| 64 | - | } | |
| 65 | - | detailed := l.containerSvc.ListRoutesWithDetails(ctx) | |
| 66 | - | if l.configSvc == nil { | |
| 67 | - | return toRemoteRouteInfos(detailed), nil | |
| 68 | - | } | |
| 69 | - | return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | if l.configSvc == nil { | |
| 73 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | routes := l.configSvc.GetRoutes(ctx) | |
| 77 | - | infos := make([]remote.RouteInfo, 0, len(routes)) | |
| 78 | - | for _, route := range routes { | |
| 79 | - | infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image}) | |
| 80 | - | } | |
| 81 | - | return infos, nil | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 85 | - | if l.healthSvc == nil { | |
| 86 | - | return map[string]*remote.RouteHealth{}, nil | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | health := l.healthSvc.CheckAllRoutes(ctx) | |
| 90 | - | result := make(map[string]*remote.RouteHealth, len(health)) | |
| 91 | - | for domainName, h := range health { | |
| 92 | - | if h == nil { | |
| 93 | - | continue | |
| 94 | - | } | |
| 95 | - | result[domainName] = &remote.RouteHealth{ | |
| 96 | - | ContainerStatus: h.ContainerStatus, | |
| 97 | - | HTTPStatus: h.HTTPStatus, | |
| 98 | - | ResponseTimeMs: h.ResponseTimeMs, | |
| 99 | - | Healthy: h.Healthy, | |
| 100 | - | Error: h.Error, | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | return result, nil | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 107 | - | if l.configSvc == nil { | |
| 108 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 109 | - | } | |
| 110 | - | return l.configSvc.GetRoute(ctx, routeDomain) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 114 | - | if l.configSvc == nil { | |
| 115 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 116 | - | } | |
| 117 | - | return l.configSvc.FindRoutesByImage(ctx, imageName), nil | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 121 | - | if l.configSvc == nil { | |
| 122 | - | return fmt.Errorf("local config service unavailable") | |
| 123 | - | } | |
| 124 | - | return l.configSvc.AddRoute(ctx, route) | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 128 | - | if l.configSvc == nil { | |
| 129 | - | return fmt.Errorf("local config service unavailable") | |
| 130 | - | } | |
| 131 | - | return l.configSvc.UpdateRoute(ctx, route) | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 135 | - | _, err := l.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 136 | - | return err | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 140 | - | if l.configSvc == nil { | |
| 141 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 142 | - | } | |
| 143 | - | if err := l.configSvc.RemoveRoute(ctx, routeDomain); err != nil && !errors.Is(err, domain.ErrRouteNotFound) { | |
| 144 | - | return nil, fmt.Errorf("remove route: %w", err) | |
| 145 | - | } | |
| 146 | - | resp := &dto.RouteDeleteResponse{Status: "removed"} | |
| 147 | - | if l.containerSvc == nil { | |
| 148 | - | return resp, nil | |
| 149 | - | } | |
| 150 | - | report, err := l.containerSvc.ReconcileRemovedRoute(ctx, routeDomain) | |
| 151 | - | if err != nil { | |
| 152 | - | return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err) | |
| 153 | - | } | |
| 154 | - | resp.Cleanup = dto.CleanupReportFromDomain(report) | |
| 155 | - | return resp, nil | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |
| 159 | - | previewer, ok := any(l.containerSvc).(interface { | |
| 160 | - | PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error) | |
| 161 | - | }) | |
| 162 | - | if !ok { | |
| 163 | - | return nil, fmt.Errorf("route cleanup preview unavailable") | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain) | |
| 167 | - | if err != nil { | |
| 168 | - | return nil, err | |
| 169 | - | } | |
| 170 | - | if report == nil { | |
| 171 | - | return nil, nil | |
| 172 | - | } | |
| 173 | - | if l.volumeSvc == nil { | |
| 174 | - | return report, nil | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | volumes, err := l.ListVolumes(ctx) | |
| 178 | - | if err != nil { | |
| 179 | - | return nil, fmt.Errorf("list volumes: %w", err) | |
| 180 | - | } | |
| 181 | - | if len(volumes) == 0 { | |
| 182 | - | return report, nil | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments) | |
| 186 | - | for _, volume := range volumesForRouteDiagnosis(volumes, scope) { | |
| 187 | - | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{ | |
| 188 | - | Name: volume.Name, | |
| 189 | - | Reason: "volume preserved for explicit cleanup review", | |
| 190 | - | }) | |
| 191 | - | } | |
| 192 | - | return report, nil | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func (l *localControlPlane) routeCleanupVolumePrefix() string { | |
| 196 | - | const defaultPrefix = "gordon" | |
| 197 | - | if l.configSvc == nil { | |
| 198 | - | return defaultPrefix | |
| 199 | - | } | |
| 200 | - | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |
| 201 | - | _, prefix, _ := volumeCfg.GetVolumeConfig() | |
| 202 | - | if prefix != "" { | |
| 203 | - | return prefix | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | return defaultPrefix | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 210 | - | registryDomain := "" | |
| 211 | - | if l.configSvc != nil { | |
| 212 | - | registryDomain = l.configSvc.GetRegistryDomain() | |
| 213 | - | } | |
| 214 | - | normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain) | |
| 215 | - | if err != nil { | |
| 216 | - | return nil, fmt.Errorf("invalid image: %w", err) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | resp := &dto.BootstrapResponse{ | |
| 220 | - | Domain: req.Domain, | |
| 221 | - | Image: normalizedImage, | |
| 222 | - | Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage), | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | if l.configSvc == nil { | |
| 226 | - | return resp, fmt.Errorf("local config service unavailable") | |
| 227 | - | } | |
| 228 | - | needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0 | |
| 229 | - | if needsSecrets && l.secretSvc == nil { | |
| 230 | - | return resp, fmt.Errorf("local secret service unavailable") | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | addStep := func(name, status string) { | |
| 234 | - | resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage}) | |
| 238 | - | switch err { | |
| 239 | - | case nil: | |
| 240 | - | addStep("route", "configured") | |
| 241 | - | default: | |
| 242 | - | addStep("route", "failed") | |
| 243 | - | return resp, err | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | if err := l.bootstrapAttachments(ctx, req, addStep); err != nil { | |
| 247 | - | return resp, err | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | if err := l.bootstrapSecrets(ctx, req, addStep); err != nil { | |
| 251 | - | return resp, err | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil { | |
| 255 | - | return resp, err | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | return resp, nil | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 262 | - | for _, attachment := range req.Attachments { | |
| 263 | - | err := l.configSvc.AddAttachment(ctx, req.Domain, attachment) | |
| 264 | - | if err == nil { | |
| 265 | - | addStep("attachment:"+attachment, "created") | |
| 266 | - | continue | |
| 267 | - | } | |
| 268 | - | if errors.Is(err, domain.ErrAttachmentExists) { | |
| 269 | - | addStep("attachment:"+attachment, "noop") | |
| 270 | - | continue | |
| 271 | - | } | |
| 272 | - | addStep("attachment:"+attachment, "failed") | |
| 273 | - | return err | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | return nil | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 280 | - | if len(req.Env) == 0 { | |
| 281 | - | return nil | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil { | |
| 285 | - | addStep("env", "failed") | |
| 286 | - | return err | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | addStep("env", "updated") | |
| 290 | - | return nil | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 294 | - | for service, env := range req.AttachmentEnv { | |
| 295 | - | if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil { | |
| 296 | - | addStep("attachment_env:"+service, "failed") | |
| 297 | - | return err | |
| 298 | - | } | |
| 299 | - | addStep("attachment_env:"+service, "updated") | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | return nil | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 306 | - | if l.secretSvc == nil { | |
| 307 | - | return nil, fmt.Errorf("local secret service unavailable") | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | |
| 311 | - | if err != nil { | |
| 312 | - | return nil, fmt.Errorf("list secret keys with attachments: %w", err) | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | result := &remote.SecretsListResult{ | |
| 316 | - | Domain: secretDomain, | |
| 317 | - | Keys: keys, | |
| 318 | - | } | |
| 319 | - | for _, att := range attachments { | |
| 320 | - | result.Attachments = append(result.Attachments, remote.AttachmentSecrets{ | |
| 321 | - | Service: att.Service, | |
| 322 | - | Keys: att.Keys, | |
| 323 | - | }) | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | return result, nil | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 330 | - | if l.secretSvc == nil { | |
| 331 | - | return fmt.Errorf("local secret service unavailable") | |
| 332 | - | } | |
| 333 | - | return l.secretSvc.Set(ctx, secretDomain, secrets) | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 337 | - | if l.secretSvc == nil { | |
| 338 | - | return fmt.Errorf("local secret service unavailable") | |
| 339 | - | } | |
| 340 | - | return l.secretSvc.Delete(ctx, secretDomain, key) | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 344 | - | if l.secretSvc == nil { | |
| 345 | - | return fmt.Errorf("local secret service unavailable") | |
| 346 | - | } | |
| 347 | - | return l.secretSvc.SetAttachment(ctx, domainName, service, secrets) | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |
| 351 | - | if l.secretSvc == nil { | |
| 352 | - | return fmt.Errorf("local secret service unavailable") | |
| 353 | - | } | |
| 354 | - | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 358 | - | if l.containerSvc == nil { | |
| 359 | - | return nil, fmt.Errorf("local container service unavailable") | |
| 360 | - | } | |
| 361 | - | attachments, err := l.containerSvc.ListOrphanedAttachments(ctx) | |
| 362 | - | if err != nil { | |
| 363 | - | return nil, fmt.Errorf("list orphaned attachments: %w", err) | |
| 364 | - | } | |
| 365 | - | return attachments, nil | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 369 | - | if l.containerSvc == nil { | |
| 370 | - | return nil, fmt.Errorf("local container service unavailable") | |
| 371 | - | } | |
| 372 | - | report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop) | |
| 373 | - | if err != nil { | |
| 374 | - | return nil, fmt.Errorf("cleanup orphaned attachments: %w", err) | |
| 375 | - | } | |
| 376 | - | return report, nil | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 380 | - | if l.configSvc == nil { | |
| 381 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 382 | - | } | |
| 383 | - | return l.configSvc.GetAllAttachments(ctx), nil | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 387 | - | if l.configSvc == nil { | |
| 388 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 389 | - | } | |
| 390 | - | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 394 | - | if l.configSvc == nil { | |
| 395 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 396 | - | } | |
| 397 | - | return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 401 | - | if l.configSvc == nil { | |
| 402 | - | return fmt.Errorf("local config service unavailable") | |
| 403 | - | } | |
| 404 | - | return l.configSvc.AddAttachment(ctx, domainOrGroup, image) | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 408 | - | if l.configSvc == nil { | |
| 409 | - | return fmt.Errorf("local config service unavailable") | |
| 410 | - | } | |
| 411 | - | return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image) | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) { | |
| 415 | - | if l.configSvc == nil { | |
| 416 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 417 | - | } | |
| 418 | - | return l.configSvc.GetAutoRouteAllowedDomains(ctx) | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 422 | - | if l.configSvc == nil { | |
| 423 | - | return fmt.Errorf("local config service unavailable") | |
| 424 | - | } | |
| 425 | - | return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern) | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 429 | - | if l.configSvc == nil { | |
| 430 | - | return fmt.Errorf("local config service unavailable") | |
| 431 | - | } | |
| 432 | - | return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern) | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 436 | - | if l.publicTLSSvc == nil { | |
| 437 | - | return &dto.TLSStatusResponse{ | |
| 438 | - | ACMEEnabled: false, | |
| 439 | - | SelectionReason: "public TLS service not configured", | |
| 440 | - | }, nil | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | status := l.publicTLSSvc.Status(ctx) | |
| 444 | - | result := dto.TLSStatusFromDomain(status) | |
| 445 | - | return &result, nil | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | |
| 449 | - | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL: %w", domain.ErrTrafficStatusUnavailable) | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 453 | - | if l.configSvc == nil { | |
| 454 | - | return nil, fmt.Errorf("local config service unavailable") | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | status := &remote.Status{ | |
| 458 | - | Routes: len(l.configSvc.GetRoutes(ctx)), | |
| 459 | - | RegistryDomain: l.configSvc.GetRegistryDomain(), | |
| 460 | - | RegistryPort: l.configSvc.GetRegistryPort(), | |
| 461 | - | ServerPort: l.configSvc.GetServerPort(), | |
| 462 | - | AutoRoute: l.configSvc.IsAutoRouteEnabled(), | |
| 463 | - | NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(), | |
| 464 | - | ContainerStatus: map[string]string{}, | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | if l.containerSvc != nil { | |
| 468 | - | for domainName, container := range l.containerSvc.List(ctx) { | |
| 469 | - | if container == nil { | |
| 470 | - | continue | |
| 471 | - | } | |
| 472 | - | status.ContainerStatus[domainName] = container.Status | |
| 473 | - | } | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | return status, nil | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | func (l *localControlPlane) Reload(_ context.Context) error { | |
| 480 | - | return app.SendReloadSignal() | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 484 | - | if l.containerSvc == nil { | |
| 485 | - | return nil, fmt.Errorf("container service unavailable") | |
| 486 | - | } | |
| 487 | - | return l.containerSvc.ListNetworks(ctx) | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | |
| 491 | - | if l.configSvc == nil { | |
| 492 | - | return nil, fmt.Errorf("config service unavailable") | |
| 493 | - | } | |
| 494 | - | externalRoutes := l.configSvc.GetExternalRoutes() | |
| 495 | - | externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes)) | |
| 496 | - | for domainName := range externalRoutes { | |
| 497 | - | externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName}) | |
| 498 | - | } | |
| 499 | - | sort.Slice(externalResponses, func(i, j int) bool { | |
| 500 | - | return externalResponses[i].Domain < externalResponses[j].Domain | |
| 501 | - | }) | |
| 502 | - | cfg := &remote.Config{ | |
| 503 | - | Routes: l.configSvc.GetRoutes(ctx), | |
| 504 | - | ExternalRoutes: externalResponses, | |
| 505 | - | } | |
| 506 | - | cfg.Server.Port = l.configSvc.GetServerPort() | |
| 507 | - | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | |
| 508 | - | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | |
| 509 | - | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | |
| 510 | - | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | |
| 511 | - | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | |
| 512 | - | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |
| 513 | - | cfg.Volumes.AutoCreate, cfg.Volumes.Prefix, cfg.Volumes.Preserve = volumeCfg.GetVolumeConfig() | |
| 514 | - | } | |
| 515 | - | return cfg, nil | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error { | |
| 519 | - | if l.deployCoord != nil { | |
| 520 | - | l.deployCoord.SuppressDeployEvent(imageName) | |
| 521 | - | } | |
| 522 | - | return nil | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 526 | - | if l.containerSvc != nil && l.configSvc != nil { | |
| 527 | - | route, err := l.configSvc.GetRoute(ctx, deployDomain) | |
| 528 | - | if err != nil { | |
| 529 | - | return nil, err | |
| 530 | - | } | |
| 531 | - | container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 532 | - | if err != nil { | |
| 533 | - | return nil, err | |
| 534 | - | } | |
| 535 | - | result := &remote.DeployResult{Status: "deployed", Domain: deployDomain} | |
| 536 | - | if container != nil { | |
| 537 | - | result.ContainerID = container.ID | |
| 538 | - | } | |
| 539 | - | return result, nil | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | domainName, err := app.SendDeploySignal(deployDomain) | |
| 543 | - | if err != nil { | |
| 544 | - | return nil, err | |
| 545 | - | } | |
| 546 | - | return &remote.DeployResult{Status: "queued", Domain: domainName}, nil | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |
| 550 | - | if l.containerSvc == nil { | |
| 551 | - | if withAttachments { | |
| 552 | - | return nil, fmt.Errorf("local restart with attachments requires active local container service") | |
| 553 | - | } | |
| 554 | - | domainName, err := app.SendDeploySignal(restartDomain) | |
| 555 | - | if err != nil { | |
| 556 | - | return nil, err | |
| 557 | - | } | |
| 558 | - | return &remote.RestartResult{Status: "queued", Domain: domainName}, nil | |
| 559 | - | } | |
| 560 | - | ||
| 561 | - | if err := l.containerSvc.SyncContainers(ctx); err != nil { | |
| 562 | - | return nil, err | |
| 563 | - | } | |
| 564 | - | if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil { | |
| 565 | - | return nil, err | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 572 | - | if l.registrySvc == nil { | |
| 573 | - | return nil, fmt.Errorf("local registry service unavailable") | |
| 574 | - | } | |
| 575 | - | return l.registrySvc.ListTags(ctx, repository) | |
| 576 | - | } | |
| 577 | - | ||
| 578 | - | func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 579 | - | if l.backupSvc == nil { | |
| 580 | - | return nil, fmt.Errorf("local backup service unavailable") | |
| 581 | - | } | |
| 582 | - | jobs, err := l.backupSvc.ListBackups(ctx, backupDomain) | |
| 583 | - | if err != nil { | |
| 584 | - | return nil, err | |
| 585 | - | } | |
| 586 | - | return toDTOBackupJobs(jobs), nil | |
| 587 | - | } | |
| 588 | - | ||
| 589 | - | func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 590 | - | if l.backupSvc == nil { | |
| 591 | - | return nil, fmt.Errorf("local backup service unavailable") | |
| 592 | - | } | |
| 593 | - | jobs, err := l.backupSvc.Status(ctx) | |
| 594 | - | if err != nil { | |
| 595 | - | return nil, err | |
| 596 | - | } | |
| 597 | - | return toDTOBackupJobs(jobs), nil | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 601 | - | if l.backupSvc == nil { | |
| 602 | - | return nil, fmt.Errorf("local backup service unavailable") | |
| 603 | - | } | |
| 604 | - | result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName) | |
| 605 | - | if err != nil { | |
| 606 | - | return nil, err | |
| 607 | - | } | |
| 608 | - | if result == nil { | |
| 609 | - | return &dto.BackupRunResponse{Status: "ok"}, nil | |
| 610 | - | } | |
| 611 | - | job := toDTOBackupJob(result.Job) | |
| 612 | - | return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil | |
| 613 | - | } | |
| 614 | - | ||
| 615 | - | func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 616 | - | if l.backupSvc == nil { | |
| 617 | - | return nil, fmt.Errorf("local backup service unavailable") | |
| 618 | - | } | |
| 619 | - | dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain) | |
| 620 | - | if err != nil { | |
| 621 | - | return nil, err | |
| 622 | - | } | |
| 623 | - | out := make([]dto.DatabaseInfo, 0, len(dbs)) | |
| 624 | - | for _, db := range dbs { | |
| 625 | - | out = append(out, dto.DatabaseInfo{ | |
| 626 | - | Type: string(db.Type), | |
| 627 | - | Name: db.Name, | |
| 628 | - | Version: db.Version, | |
| 629 | - | Host: db.Host, | |
| 630 | - | Port: db.Port, | |
| 631 | - | ContainerID: db.ContainerID, | |
| 632 | - | ImageName: db.ImageName, | |
| 633 | - | }) | |
| 634 | - | } | |
| 635 | - | return out, nil | |
| 636 | - | } | |
| 637 | - | ||
| 638 | - | func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) { | |
| 639 | - | if l.volumeBackupSvc == nil { | |
| 640 | - | return nil, localVolumeBackupServiceUnavailable() | |
| 641 | - | } | |
| 642 | - | jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain) | |
| 643 | - | if err != nil { | |
| 644 | - | return nil, fmt.Errorf("list volume backups: %w", err) | |
| 645 | - | } | |
| 646 | - | return toDTOVolumeBackupJobs(jobs), nil | |
| 647 | - | } | |
| 648 | - | ||
| 649 | - | func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) { | |
| 650 | - | if l.volumeBackupSvc == nil { | |
| 651 | - | return nil, localVolumeBackupServiceUnavailable() | |
| 652 | - | } | |
| 653 | - | jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx) | |
| 654 | - | if err != nil { | |
| 655 | - | return nil, fmt.Errorf("get volume backup status: %w", err) | |
| 656 | - | } | |
| 657 | - | return toDTOVolumeBackupJobs(jobs), nil | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) { | |
| 661 | - | if l.volumeBackupSvc == nil { | |
| 662 | - | return nil, localVolumeBackupServiceUnavailable() | |
| 663 | - | } | |
| 664 | - | jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName) | |
| 665 | - | if err != nil { | |
| 666 | - | if len(jobs) > 0 { | |
| 667 | - | resp := &dto.VolumeBackupRunResponse{Status: "partial", Backups: toDTOVolumeBackupJobs(jobs), Error: err.Error()} | |
| 668 | - | return resp, fmt.Errorf("run volume backups: %w", err) | |
| 669 | - | } | |
| 670 | - | return nil, fmt.Errorf("run volume backups: %w", err) | |
| 671 | - | } | |
| 672 | - | return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil | |
| 673 | - | } | |
| 674 | - | ||
| 675 | - | func localVolumeBackupServiceUnavailable() error { | |
| 676 | - | return fmt.Errorf("local volume backup service unavailable: %w", domain.ErrVolumeBackupUnavailable) | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |
| 680 | - | if l.logSvc == nil { | |
| 681 | - | return nil, fmt.Errorf("local log service unavailable") | |
| 682 | - | } | |
| 683 | - | return l.logSvc.GetProcessLogs(ctx, lines) | |
| 684 | - | } | |
| 685 | - | ||
| 686 | - | func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 687 | - | if l.logSvc == nil { | |
| 688 | - | return nil, fmt.Errorf("local log service unavailable") | |
| 689 | - | } | |
| 690 | - | return l.logSvc.GetContainerLogs(ctx, logDomain, lines) | |
| 691 | - | } | |
| 692 | - | ||
| 693 | - | func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 694 | - | if l.logSvc == nil { | |
| 695 | - | return nil, fmt.Errorf("local log service unavailable") | |
| 696 | - | } | |
| 697 | - | return l.logSvc.FollowProcessLogs(ctx, lines) | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 701 | - | if l.logSvc == nil { | |
| 702 | - | return nil, fmt.Errorf("local log service unavailable") | |
| 703 | - | } | |
| 704 | - | return l.logSvc.FollowContainerLogs(ctx, logDomain, lines) | |
| 705 | - | } | |
| 706 | - | ||
| 707 | - | func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) { | |
| 708 | - | if l.volumeSvc == nil { | |
| 709 | - | return nil, fmt.Errorf("volume service unavailable") | |
| 710 | - | } | |
| 711 | - | vols, err := l.volumeSvc.ListVolumes(ctx) | |
| 712 | - | if err != nil { | |
| 713 | - | return nil, err | |
| 714 | - | } | |
| 715 | - | ||
| 716 | - | result := make([]dto.Volume, len(vols)) | |
| 717 | - | for i, v := range vols { | |
| 718 | - | result[i] = dto.Volume{ | |
| 719 | - | Name: v.Name, | |
| 720 | - | Driver: v.Driver, | |
| 721 | - | MountPoint: v.MountPoint, | |
| 722 | - | Size: v.Size, | |
| 723 | - | CreatedAt: v.CreatedAt, | |
| 724 | - | InUse: v.InUse, | |
| 725 | - | Containers: v.Containers, | |
| 726 | - | Labels: v.Labels, | |
| 727 | - | } | |
| 728 | - | } | |
| 729 | - | return result, nil | |
| 730 | - | } | |
| 731 | - | ||
| 732 | - | func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) { | |
| 733 | - | if l.volumeSvc == nil { | |
| 734 | - | return nil, fmt.Errorf("volume service unavailable") | |
| 735 | - | } | |
| 736 | - | report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun) | |
| 737 | - | if err != nil { | |
| 738 | - | return nil, err | |
| 739 | - | } | |
| 740 | - | ||
| 741 | - | vols := make([]dto.Volume, len(removed)) | |
| 742 | - | for i, v := range removed { | |
| 743 | - | vols[i] = dto.Volume{ | |
| 744 | - | Name: v.Name, | |
| 745 | - | Size: v.Size, | |
| 746 | - | } | |
| 747 | - | } | |
| 748 | - | ||
| 749 | - | return &dto.VolumePruneResponse{ | |
| 750 | - | VolumesRemoved: report.VolumesRemoved, | |
| 751 | - | SpaceReclaimed: report.SpaceReclaimed, | |
| 752 | - | Volumes: vols, | |
| 753 | - | }, nil | |
| 754 | - | } | |
| 755 | - | ||
| 756 | - | func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo { | |
| 757 | - | detailedByDomain := make(map[string]domain.RouteInfo, len(detailed)) | |
| 758 | - | for _, info := range detailed { | |
| 759 | - | detailedByDomain[info.Domain] = info | |
| 760 | - | } | |
| 761 | - | ||
| 762 | - | merged := make([]domain.RouteInfo, 0, len(configured)) | |
| 763 | - | for _, route := range configured { | |
| 764 | - | info, ok := detailedByDomain[route.Domain] | |
| 765 | - | if !ok { | |
| 766 | - | info = domain.RouteInfo{Domain: route.Domain, Image: route.Image} | |
| 767 | - | } else if info.Image == "" { | |
| 768 | - | info.Image = route.Image | |
| 769 | - | } | |
| 770 | - | merged = append(merged, info) | |
| 771 | - | } | |
| 772 | - | ||
| 773 | - | return toRemoteRouteInfos(merged) | |
| 774 | - | } | |
| 775 | - | ||
| 776 | - | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | |
| 777 | - | out := make([]remote.RouteInfo, 0, len(routes)) | |
| 778 | - | for _, route := range routes { | |
| 779 | - | attachments := make([]dto.Attachment, 0, len(route.Attachments)) | |
| 780 | - | for _, attachment := range route.Attachments { | |
| 781 | - | attachments = append(attachments, dto.Attachment{ | |
| 782 | - | Name: attachment.Name, | |
| 783 | - | Image: attachment.Image, | |
| 784 | - | ContainerID: attachment.ContainerID, | |
| 785 | - | Status: attachment.Status, | |
| 786 | - | Network: attachment.Network, | |
| 787 | - | }) | |
| 788 | - | } | |
| 789 | - | ||
| 790 | - | out = append(out, remote.RouteInfo{ | |
| 791 | - | Domain: route.Domain, | |
| 792 | - | Image: route.Image, | |
| 793 | - | ContainerID: route.ContainerID, | |
| 794 | - | ContainerStatus: route.ContainerStatus, | |
| 795 | - | Network: route.Network, | |
| 796 | - | Attachments: attachments, | |
| 797 | - | }) | |
| 798 | - | } | |
| 799 | - | return out | |
| 800 | - | } | |
| 801 | - | ||
| 802 | - | func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |
| 803 | - | out := make([]dto.BackupJob, 0, len(jobs)) | |
| 804 | - | for _, job := range jobs { | |
| 805 | - | out = append(out, toDTOBackupJob(job)) | |
| 806 | - | } | |
| 807 | - | return out | |
| 808 | - | } | |
| 809 | - | ||
| 810 | - | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |
| 811 | - | var startedAt *time.Time | |
| 812 | - | if !job.StartedAt.IsZero() { | |
| 813 | - | t := job.StartedAt | |
| 814 | - | startedAt = &t | |
| 815 | - | } | |
| 816 | - | var completedAt *time.Time | |
| 817 | - | if !job.CompletedAt.IsZero() { | |
| 818 | - | t := job.CompletedAt | |
| 819 | - | completedAt = &t | |
| 820 | - | } | |
| 821 | - | ||
| 822 | - | return dto.BackupJob{ | |
| 823 | - | ID: job.ID, | |
| 824 | - | Domain: job.Domain, | |
| 825 | - | DBName: job.DBName, | |
| 826 | - | Schedule: string(job.Schedule), | |
| 827 | - | Type: string(job.Type), | |
| 828 | - | Status: string(job.Status), | |
| 829 | - | StartedAt: startedAt, | |
| 830 | - | CompletedAt: completedAt, | |
| 831 | - | SizeBytes: job.SizeBytes, | |
| 832 | - | Error: job.Error, | |
| 833 | - | } | |
| 834 | - | } | |
| 835 | - | ||
| 836 | - | func toDTOVolumeBackupJobs(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob { | |
| 837 | - | out := make([]dto.VolumeBackupJob, 0, len(jobs)) | |
| 838 | - | for _, job := range jobs { | |
| 839 | - | out = append(out, toDTOVolumeBackupJob(job)) | |
| 840 | - | } | |
| 841 | - | return out | |
| 842 | - | } | |
| 843 | - | ||
| 844 | - | func toDTOVolumeBackupJob(job domain.VolumeBackupJob) dto.VolumeBackupJob { | |
| 845 | - | var startedAt *time.Time | |
| 846 | - | if !job.StartedAt.IsZero() { | |
| 847 | - | t := job.StartedAt | |
| 848 | - | startedAt = &t | |
| 849 | - | } | |
| 850 | - | var completedAt *time.Time | |
| 851 | - | if !job.CompletedAt.IsZero() { | |
| 852 | - | t := job.CompletedAt | |
| 853 | - | completedAt = &t | |
| 854 | - | } | |
| 855 | - | ||
| 856 | - | return dto.VolumeBackupJob{ | |
| 857 | - | ID: job.ID, | |
| 858 | - | Domain: job.Domain, | |
| 859 | - | ContainerName: job.ContainerName, | |
| 860 | - | ContainerID: job.ContainerID, | |
| 861 | - | VolumeName: job.VolumeName, | |
| 862 | - | MountPath: job.MountPath, | |
| 863 | - | Compression: job.Metadata["compression"], | |
| 864 | - | Type: string(job.Type), | |
| 865 | - | Status: string(job.Status), | |
| 866 | - | StartedAt: startedAt, | |
| 867 | - | CompletedAt: completedAt, | |
| 868 | - | SizeBytes: job.SizeBytes, | |
| 869 | - | ArtifactRef: job.ArtifactRef, | |
| 870 | - | Error: job.Error, | |
| 871 | - | } | |
| 872 | - | } | |
D internal/adapters/in/cli/controlplane_local_test.go +0 -308
| @@ -1,308 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "testing" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/adapters/dto" | |
| 10 | - | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 11 | - | "github.com/bnema/gordon/internal/domain" | |
| 12 | - | "github.com/stretchr/testify/assert" | |
| 13 | - | "github.com/stretchr/testify/mock" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | type previewContainerService struct { | |
| 18 | - | *inmocks.MockContainerService | |
| 19 | - | preview func(context.Context, string) (*domain.CleanupReport, error) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |
| 23 | - | if s.preview == nil { | |
| 24 | - | return nil, nil | |
| 25 | - | } | |
| 26 | - | return s.preview(ctx, routeDomain) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestLocalControlPlane_GetStatus(t *testing.T) { | |
| 30 | - | t.Parallel() | |
| 31 | - | ||
| 32 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 33 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 34 | - | ||
| 35 | - | ctx := context.Background() | |
| 36 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}}) | |
| 37 | - | configSvc.EXPECT().GetRegistryDomain().Return("registry.local") | |
| 38 | - | configSvc.EXPECT().GetRegistryPort().Return(5000) | |
| 39 | - | configSvc.EXPECT().GetServerPort().Return(80) | |
| 40 | - | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 41 | - | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true) | |
| 42 | - | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 43 | - | "app.local": {ID: "abc123", Status: "running"}, | |
| 44 | - | }) | |
| 45 | - | ||
| 46 | - | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 47 | - | status, err := cp.GetStatus(ctx) | |
| 48 | - | require.NoError(t, err) | |
| 49 | - | require.Equal(t, 1, status.Routes) | |
| 50 | - | require.Equal(t, "registry.local", status.RegistryDomain) | |
| 51 | - | require.Equal(t, "running", status.ContainerStatus["app.local"]) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) { | |
| 55 | - | t.Parallel() | |
| 56 | - | ||
| 57 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 58 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 59 | - | ||
| 60 | - | ctx := context.Background() | |
| 61 | - | route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"} | |
| 62 | - | ||
| 63 | - | require.False(t, domain.IsInternalDeploy(ctx)) | |
| 64 | - | ||
| 65 | - | configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil) | |
| 66 | - | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |
| 67 | - | require.True(t, domain.IsInternalDeploy(deployCtx)) | |
| 68 | - | require.Equal(t, *route, deployedRoute) | |
| 69 | - | return &domain.Container{ID: "container-1"}, nil | |
| 70 | - | }) | |
| 71 | - | ||
| 72 | - | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 73 | - | result, err := cp.Deploy(ctx, "app.local") | |
| 74 | - | require.NoError(t, err) | |
| 75 | - | require.NotNil(t, result) | |
| 76 | - | require.Equal(t, "deployed", result.Status) | |
| 77 | - | require.Equal(t, "app.local", result.Domain) | |
| 78 | - | require.Equal(t, "container-1", result.ContainerID) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func TestLocalControlPlane_Backups(t *testing.T) { | |
| 82 | - | t.Parallel() | |
| 83 | - | ||
| 84 | - | backupSvc := inmocks.NewMockBackupService(t) | |
| 85 | - | ctx := context.Background() | |
| 86 | - | ||
| 87 | - | now := time.Now().UTC() | |
| 88 | - | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}} | |
| 89 | - | backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil) | |
| 90 | - | backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil) | |
| 91 | - | backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil) | |
| 92 | - | backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil) | |
| 93 | - | ||
| 94 | - | cp := &localControlPlane{backupSvc: backupSvc} | |
| 95 | - | ||
| 96 | - | list, err := cp.ListBackups(ctx, "app.local") | |
| 97 | - | require.NoError(t, err) | |
| 98 | - | require.Len(t, list, 1) | |
| 99 | - | ||
| 100 | - | status, err := cp.BackupStatus(ctx) | |
| 101 | - | require.NoError(t, err) | |
| 102 | - | require.Len(t, status, 1) | |
| 103 | - | ||
| 104 | - | run, err := cp.RunBackup(ctx, "app.local", "postgres") | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | require.NotNil(t, run.Backup) | |
| 107 | - | require.Equal(t, "b1", run.Backup.ID) | |
| 108 | - | ||
| 109 | - | dbs, err := cp.DetectDatabases(ctx, "app.local") | |
| 110 | - | require.NoError(t, err) | |
| 111 | - | require.Len(t, dbs, 1) | |
| 112 | - | require.Equal(t, "postgres", dbs[0].Name) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) { | |
| 116 | - | t.Parallel() | |
| 117 | - | ||
| 118 | - | volumeBackupSvc := inmocks.NewMockVolumeBackupService(t) | |
| 119 | - | ctx := context.Background() | |
| 120 | - | runErr := errors.New("one volume failed") | |
| 121 | - | jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}} | |
| 122 | - | volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr) | |
| 123 | - | ||
| 124 | - | cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc} | |
| 125 | - | result, err := cp.RunVolumeBackups(ctx, "app.local", "") | |
| 126 | - | ||
| 127 | - | require.ErrorIs(t, err, runErr) | |
| 128 | - | require.NotNil(t, result) | |
| 129 | - | assert.Equal(t, "partial", result.Status) | |
| 130 | - | assert.Equal(t, runErr.Error(), result.Error) | |
| 131 | - | require.Len(t, result.Backups, 1) | |
| 132 | - | assert.Equal(t, "v1", result.Backups[0].ID) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) { | |
| 136 | - | t.Parallel() | |
| 137 | - | ||
| 138 | - | cp := &localControlPlane{} | |
| 139 | - | result, err := cp.RunVolumeBackups(context.Background(), "app.local", "") | |
| 140 | - | ||
| 141 | - | require.Error(t, err) | |
| 142 | - | assert.Nil(t, result) | |
| 143 | - | assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) { | |
| 147 | - | t.Parallel() | |
| 148 | - | ||
| 149 | - | volumeBackupSvc := inmocks.NewMockVolumeBackupService(t) | |
| 150 | - | wantErr := errors.New("store unavailable") | |
| 151 | - | volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr) | |
| 152 | - | ||
| 153 | - | cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc} | |
| 154 | - | jobs, err := cp.ListVolumeBackups(context.Background(), "app.local") | |
| 155 | - | ||
| 156 | - | require.ErrorIs(t, err, wantErr) | |
| 157 | - | assert.Contains(t, err.Error(), "list volume backups") | |
| 158 | - | assert.Nil(t, jobs) | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func TestLocalControlPlane_ListTags(t *testing.T) { | |
| 162 | - | t.Parallel() | |
| 163 | - | ||
| 164 | - | registrySvc := inmocks.NewMockRegistryService(t) | |
| 165 | - | registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil) | |
| 166 | - | ||
| 167 | - | cp := &localControlPlane{registrySvc: registrySvc} | |
| 168 | - | tags, err := cp.ListTags(context.Background(), "repo/app") | |
| 169 | - | require.NoError(t, err) | |
| 170 | - | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) { | |
| 174 | - | t.Parallel() | |
| 175 | - | ||
| 176 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 177 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 178 | - | report := &domain.CleanupReport{ | |
| 179 | - | Domain: "app.local", | |
| 180 | - | RemovedContainers: []domain.CleanupContainer{{ | |
| 181 | - | ID: "container-1", | |
| 182 | - | Name: "gordon-app.local", | |
| 183 | - | }}, | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once() | |
| 187 | - | cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |
| 188 | - | mock.InOrder(removeCall, cleanupCall) | |
| 189 | - | ||
| 190 | - | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 191 | - | err := cp.RemoveRoute(context.Background(), "app.local") | |
| 192 | - | require.NoError(t, err) | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |
| 196 | - | t.Parallel() | |
| 197 | - | ||
| 198 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 199 | - | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 200 | - | containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil) | |
| 201 | - | ||
| 202 | - | cp := &localControlPlane{containerSvc: containerSvc} | |
| 203 | - | result, err := cp.Restart(context.Background(), "app.local", true) | |
| 204 | - | require.NoError(t, err) | |
| 205 | - | require.Equal(t, "app.local", result.Domain) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) { | |
| 209 | - | t.Parallel() | |
| 210 | - | ||
| 211 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 212 | - | syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |
| 213 | - | listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{ | |
| 214 | - | Domain: "app.local", | |
| 215 | - | Image: "repo/app:latest", | |
| 216 | - | ContainerID: "container-1", | |
| 217 | - | ContainerStatus: "running", | |
| 218 | - | }}).Once() | |
| 219 | - | mock.InOrder(syncCall, listCall) | |
| 220 | - | ||
| 221 | - | cp := &localControlPlane{containerSvc: containerSvc} | |
| 222 | - | routes, err := cp.ListRoutesWithDetails(context.Background()) | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | require.Len(t, routes, 1) | |
| 225 | - | require.Equal(t, "app.local", routes[0].Domain) | |
| 226 | - | require.Equal(t, "repo/app:latest", routes[0].Image) | |
| 227 | - | require.Equal(t, "container-1", routes[0].ContainerID) | |
| 228 | - | require.Equal(t, "running", routes[0].ContainerStatus) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) { | |
| 232 | - | t.Parallel() | |
| 233 | - | ||
| 234 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 235 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 236 | - | ||
| 237 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |
| 238 | - | Domain: "app.local", | |
| 239 | - | Image: "repo/app:latest", | |
| 240 | - | }}).Once() | |
| 241 | - | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |
| 242 | - | containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() | |
| 243 | - | ||
| 244 | - | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 245 | - | routes, err := cp.ListRoutesWithDetails(context.Background()) | |
| 246 | - | require.NoError(t, err) | |
| 247 | - | require.Len(t, routes, 1) | |
| 248 | - | require.Equal(t, "app.local", routes[0].Domain) | |
| 249 | - | require.Equal(t, "repo/app:latest", routes[0].Image) | |
| 250 | - | require.Empty(t, routes[0].ContainerID) | |
| 251 | - | require.Empty(t, routes[0].ContainerStatus) | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) { | |
| 255 | - | t.Parallel() | |
| 256 | - | ||
| 257 | - | cp := &localControlPlane{} | |
| 258 | - | status, err := cp.GetTLSStatus(context.Background()) | |
| 259 | - | require.NoError(t, err) | |
| 260 | - | require.Equal(t, &dto.TLSStatusResponse{ | |
| 261 | - | ACMEEnabled: false, | |
| 262 | - | SelectionReason: "public TLS service not configured", | |
| 263 | - | }, status) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |
| 267 | - | t.Parallel() | |
| 268 | - | ||
| 269 | - | logSvc := inmocks.NewMockLogService(t) | |
| 270 | - | logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil) | |
| 271 | - | ||
| 272 | - | cp := &localControlPlane{logSvc: logSvc} | |
| 273 | - | lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50) | |
| 274 | - | require.NoError(t, err) | |
| 275 | - | require.Equal(t, []string{"line1", "line2"}, lines) | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) { | |
| 279 | - | ctx := context.Background() | |
| 280 | - | volumeSvc := inmocks.NewMockVolumeService(t) | |
| 281 | - | containerSvc := &previewContainerService{ | |
| 282 | - | MockContainerService: inmocks.NewMockContainerService(t), | |
| 283 | - | preview: func(context.Context, string) (*domain.CleanupReport, error) { | |
| 284 | - | return nil, nil | |
| 285 | - | }, | |
| 286 | - | } | |
| 287 | - | cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc} | |
| 288 | - | ||
| 289 | - | report, err := cp.GetRouteCleanupPreview(ctx, "app.local") | |
| 290 | - | require.NoError(t, err) | |
| 291 | - | assert.Nil(t, report) | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |
| 295 | - | ctx := context.Background() | |
| 296 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 297 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 298 | - | report := &domain.CleanupReport{Domain: "app.local"} | |
| 299 | - | ||
| 300 | - | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once() | |
| 301 | - | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |
| 302 | - | ||
| 303 | - | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 304 | - | resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local") | |
| 305 | - | require.NoError(t, err) | |
| 306 | - | require.NotNil(t, resp) | |
| 307 | - | assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup) | |
| 308 | - | } | |
M internal/adapters/in/cli/controlplane_remote.go +40 -102
| @@ -9,56 +9,66 @@ import ( | |||
| 9 | 9 | "github.com/bnema/gordon/internal/domain" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | // remoteControlPlane delegates every CLI operation to one daemon client: | |
| 13 | + | // the explicit remote or the owner-only local admin socket. | |
| 12 | 14 | type remoteControlPlane struct { | |
| 13 | 15 | client *remote.Client | |
| 14 | 16 | } | |
| 15 | 17 | ||
| 18 | + | // NewRemoteControlPlane creates the daemon-backed control plane. | |
| 16 | 19 | func NewRemoteControlPlane(client *remote.Client) ControlPlane { | |
| 17 | 20 | return &remoteControlPlane{client: client} | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 20 | - | func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 21 | - | return r.client.ListRoutesWithDetails(ctx) | |
| 23 | + | func (r *remoteControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) { | |
| 24 | + | return r.client.ApplyApp(ctx, req) | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 24 | - | func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 25 | - | return r.client.GetHealth(ctx) | |
| 27 | + | func (r *remoteControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) { | |
| 28 | + | return r.client.ListApps(ctx) | |
| 26 | 29 | } | |
| 27 | 30 | ||
| 28 | - | func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 29 | - | return r.client.GetRoute(ctx, routeDomain) | |
| 31 | + | func (r *remoteControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | |
| 32 | + | return r.client.ShowApp(ctx, app) | |
| 30 | 33 | } | |
| 31 | 34 | ||
| 32 | - | func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 33 | - | return r.client.FindRoutesByImage(ctx, imageName) | |
| 35 | + | func (r *remoteControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) { | |
| 36 | + | return r.client.DiffApp(ctx, app) | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | - | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 37 | - | return r.client.AddRoute(ctx, route) | |
| 39 | + | func (r *remoteControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | |
| 40 | + | return r.client.DeployApp(ctx, app, req) | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | - | func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 41 | - | return r.client.UpdateRoute(ctx, route) | |
| 43 | + | func (r *remoteControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 44 | + | return r.client.StopApp(ctx, app) | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | - | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 45 | - | _, err := r.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 46 | - | return err | |
| 47 | + | func (r *remoteControlPlane) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 48 | + | return r.client.StartApp(ctx, app) | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | - | func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 50 | - | return r.client.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 51 | + | func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) { | |
| 52 | + | return r.client.RestartApp(ctx, app, service) | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | - | func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |
| 54 | - | return r.client.GetRouteCleanupPreview(ctx, routeDomain) | |
| 55 | + | func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 56 | + | return r.client.RemoveApp(ctx, app) | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | - | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 58 | - | return r.client.Bootstrap(ctx, req) | |
| 59 | + | func (r *remoteControlPlane) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) { | |
| 60 | + | return r.client.OperationByKey(ctx, app, key) | |
| 59 | 61 | } | |
| 60 | 62 | ||
| 61 | - | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 63 | + | func (r *remoteControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error { | |
| 64 | + | return r.client.SetAppSecrets(ctx, app, req) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *remoteControlPlane) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error { | |
| 68 | + | return r.client.DeleteAppSecret(ctx, app, req) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (r *remoteControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 62 | 72 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 63 | 73 | } | |
| 64 | 74 | ||
| @@ -70,54 +80,6 @@ func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key | |||
| 70 | 80 | return r.client.DeleteSecret(ctx, secretDomain, key) | |
| 71 | 81 | } | |
| 72 | 82 | ||
| 73 | - | func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 74 | - | return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |
| 78 | - | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func (r *remoteControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 82 | - | return r.client.ListOrphanedAttachments(ctx) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func (r *remoteControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 86 | - | return r.client.CleanupOrphanedAttachments(ctx, owner, stop) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 90 | - | return r.client.GetAllAttachmentsConfig(ctx) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 94 | - | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func (r *remoteControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 98 | - | return r.client.FindAttachmentTargetsByImage(ctx, imageName) | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 102 | - | return r.client.AddAttachment(ctx, domainOrGroup, image) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 106 | - | return r.client.RemoveAttachment(ctx, domainOrGroup, image) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | func (r *remoteControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) { | |
| 110 | - | return r.client.GetAutoRouteAllowedDomains(ctx) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (r *remoteControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 114 | - | return r.client.AddAutoRouteAllowedDomain(ctx, pattern) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func (r *remoteControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 118 | - | return r.client.RemoveAutoRouteAllowedDomain(ctx, pattern) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | 83 | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 122 | 84 | return r.client.GetStatus(ctx) | |
| 123 | 85 | } | |
| @@ -142,40 +104,24 @@ func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, err | |||
| 142 | 104 | return r.client.GetConfig(ctx) | |
| 143 | 105 | } | |
| 144 | 106 | ||
| 145 | - | func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error { | |
| 146 | - | return r.client.DeployIntent(ctx, imageName) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 150 | - | return r.client.Deploy(ctx, deployDomain) | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |
| 154 | - | return r.client.Restart(ctx, restartDomain, withAttachments) | |
| 155 | - | } | |
| 156 | - | ||
| 157 | 107 | func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 158 | 108 | return r.client.ListTags(ctx, repository) | |
| 159 | 109 | } | |
| 160 | 110 | ||
| 161 | - | func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 162 | - | return r.client.ListBackups(ctx, backupDomain) | |
| 111 | + | func (r *remoteControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) { | |
| 112 | + | return r.client.ListBackups(ctx, app) | |
| 163 | 113 | } | |
| 164 | 114 | ||
| 165 | 115 | func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 166 | 116 | return r.client.BackupStatus(ctx) | |
| 167 | 117 | } | |
| 168 | 118 | ||
| 169 | - | func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 170 | - | return r.client.RunBackup(ctx, backupDomain, dbName) | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 174 | - | return r.client.DetectDatabases(ctx, backupDomain) | |
| 119 | + | func (r *remoteControlPlane) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) { | |
| 120 | + | return r.client.RunBackup(ctx, app, service, database) | |
| 175 | 121 | } | |
| 176 | 122 | ||
| 177 | - | func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) { | |
| 178 | - | jobs, err := r.client.ListVolumeBackups(ctx, backupDomain) | |
| 123 | + | func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) { | |
| 124 | + | jobs, err := r.client.ListVolumeBackups(ctx, app) | |
| 179 | 125 | if err != nil { | |
| 180 | 126 | return nil, fmt.Errorf("list volume backups: %w", err) | |
| 181 | 127 | } | |
| @@ -190,8 +136,8 @@ func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.Volu | |||
| 190 | 136 | return jobs, nil | |
| 191 | 137 | } | |
| 192 | 138 | ||
| 193 | - | func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) { | |
| 194 | - | result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName) | |
| 139 | + | func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) { | |
| 140 | + | result, err := r.client.RunVolumeBackups(ctx, app, service, volume) | |
| 195 | 141 | if err != nil { | |
| 196 | 142 | return result, fmt.Errorf("run volume backups: %w", err) | |
| 197 | 143 | } | |
| @@ -202,18 +148,10 @@ func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]s | |||
| 202 | 148 | return r.client.GetProcessLogs(ctx, lines) | |
| 203 | 149 | } | |
| 204 | 150 | ||
| 205 | - | func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 206 | - | return r.client.GetContainerLogs(ctx, logDomain, lines) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | 151 | func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 210 | 152 | return r.client.StreamProcessLogs(ctx, lines) | |
| 211 | 153 | } | |
| 212 | 154 | ||
| 213 | - | func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 214 | - | return r.client.StreamContainerLogs(ctx, logDomain, lines) | |
| 215 | - | } | |
| 216 | - | ||
| 217 | 155 | func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) { | |
| 218 | 156 | return r.client.ListVolumes(ctx) | |
| 219 | 157 | } | |
M internal/adapters/in/cli/controlplane_remote_test.go +2 -2
| @@ -30,14 +30,14 @@ func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) | |||
| 30 | 30 | w.WriteHeader(http.StatusPartialContent) | |
| 31 | 31 | require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{ | |
| 32 | 32 | Status: "partial", | |
| 33 | - | Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}}, | |
| 33 | + | Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data"}}, | |
| 34 | 34 | Error: "one volume failed", | |
| 35 | 35 | })) | |
| 36 | 36 | })) | |
| 37 | 37 | t.Cleanup(server.Close) | |
| 38 | 38 | ||
| 39 | 39 | cp := NewRemoteControlPlane(remote.NewClient(server.URL)) | |
| 40 | - | result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "") | |
| 40 | + | result, err := cp.RunVolumeBackups(context.Background(), "shop", "api", "data") | |
| 41 | 41 | ||
| 42 | 42 | require.Error(t, err) | |
| 43 | 43 | assert.Contains(t, err.Error(), "run volume backups") | |
M internal/adapters/in/cli/controlplane_resolver.go +20 -36
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 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,16 +23,27 @@ func (h *controlPlaneHandle) close() { | |||
| 24 | 23 | } | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 27 | - | func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 26 | + | var newLocalControlPlaneClient = remote.NewLocalClient | |
| 27 | + | ||
| 28 | + | func resolveDaemonClient() (*remote.Client, bool, error) { | |
| 28 | 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: NewRemoteControlPlane(client), isRemote: isRemote}, nil | |
| 37 | 47 | } | |
| 38 | 48 | ||
| 39 | 49 | func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle { | |
| @@ -41,22 +51,8 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan | |||
| 41 | 51 | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | |
| 42 | 52 | } | |
| 43 | 53 | ||
| 44 | - | func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |
| 45 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 46 | - | return inferRemoteForRouteDomain(ctx, routeDomain) | |
| 47 | - | }) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |
| 51 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 52 | - | return inferRemoteForRouteCleanupDomain(ctx, routeDomain) | |
| 53 | - | }) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) { | |
| 57 | - | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 58 | - | return inferRemoteForAttachmentTarget(ctx, target) | |
| 59 | - | }) | |
| 54 | + | func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) { | |
| 55 | + | return resolveControlPlaneWithInference(ctx, inferExplicitTarget) | |
| 60 | 56 | } | |
| 61 | 57 | ||
| 62 | 58 | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | |
| @@ -73,17 +69,5 @@ func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Co | |||
| 73 | 69 | if resolved != nil { | |
| 74 | 70 | return newRemoteControlPlaneHandle(resolved), nil | |
| 75 | 71 | } | |
| 76 | - | return resolveControlPlane(configPath) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 80 | - | kernel, err := app.NewKernelQuiet(configPath) | |
| 81 | - | if err != nil { | |
| 82 | - | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | return &controlPlaneHandle{ | |
| 86 | - | plane: NewLocalControlPlane(kernel), | |
| 87 | - | closeFn: kernel.Close, | |
| 88 | - | }, nil | |
| 72 | + | return resolveControlPlane(cliConfigPath) | |
| 89 | 73 | } | |
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, &remoteControlPlane{}, 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, &remoteControlPlane{}, 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 | } | |
D internal/adapters/in/cli/deploy.go +0 -114
| @@ -1,114 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | ||
| 8 | - | "github.com/spf13/cobra" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 11 | - | "github.com/bnema/gordon/internal/app" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | type deployer interface { | |
| 15 | - | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | var sendDeploySignal = app.SendDeploySignal | |
| 19 | - | ||
| 20 | - | // newDeployCmd creates the deploy command. | |
| 21 | - | func newDeployCmd() *cobra.Command { | |
| 22 | - | var jsonOut bool | |
| 23 | - | cmd := &cobra.Command{ | |
| 24 | - | Use: "deploy <domain>", | |
| 25 | - | Short: "Manually deploy or redeploy a route", | |
| 26 | - | Long: `Triggers a deployment for the specified route domain. | |
| 27 | - | The route must be configured in config.toml. | |
| 28 | - | ||
| 29 | - | This will pull the latest image and deploy/redeploy the container, | |
| 30 | - | even if a container is already running. | |
| 31 | - | ||
| 32 | - | Examples: | |
| 33 | - | gordon deploy myapp.example.com | |
| 34 | - | gordon deploy api.example.com | |
| 35 | - | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 36 | - | Args: cobra.ExactArgs(1), | |
| 37 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 38 | - | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) | |
| 39 | - | if err != nil { | |
| 40 | - | return err | |
| 41 | - | } | |
| 42 | - | defer handle.close() | |
| 43 | - | ||
| 44 | - | return runDeploy(cmd.Context(), handle.plane, handle.isRemote, args[0], cmd.OutOrStdout(), jsonOut) | |
| 45 | - | }, | |
| 46 | - | } | |
| 47 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 48 | - | return cmd | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDomain string, out io.Writer, jsonOut bool) error { | |
| 52 | - | result, err := deployer.Deploy(ctx, deployDomain) | |
| 53 | - | if err != nil { | |
| 54 | - | if formatted, ok := structuredDeployFailure(err); ok { | |
| 55 | - | return formatted | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | if isRemote && shouldFallbackToLocal(err) { | |
| 59 | - | return handleLocalDeployFallback(err, deployDomain, out, jsonOut) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | return formatDeployFailure(err) | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | if jsonOut { | |
| 66 | - | return writeJSON(out, result) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | messageDomain := deployDomain | |
| 70 | - | if result.Domain != "" { | |
| 71 | - | messageDomain = result.Domain | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | containerID := shortContainerID(result.ContainerID) | |
| 75 | - | ||
| 76 | - | switch result.Status { | |
| 77 | - | case "deployed": | |
| 78 | - | msg := fmt.Sprintf("Deployed %s", messageDomain) | |
| 79 | - | if containerID != "" { | |
| 80 | - | msg += fmt.Sprintf(" (container: %s)", containerID) | |
| 81 | - | } | |
| 82 | - | return cliWriteLine(out, cliRenderSuccess(msg)) | |
| 83 | - | case "queued": | |
| 84 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy queued for domain: %s", messageDomain))) | |
| 85 | - | default: | |
| 86 | - | msg := fmt.Sprintf("Deploy status for %s: %s", messageDomain, result.Status) | |
| 87 | - | if containerID != "" { | |
| 88 | - | msg += fmt.Sprintf(" (container: %s)", containerID) | |
| 89 | - | } | |
| 90 | - | return cliWriteLine(out, cliRenderInfo(msg)) | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func handleLocalDeployFallback(err error, deployDomain string, out io.Writer, jsonOut bool) error { | |
| 95 | - | domain, localErr := sendDeploySignal(deployDomain) | |
| 96 | - | if localErr != nil { | |
| 97 | - | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %w", err, localErr) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | warning := sanitizeDeployLogLine(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)) | |
| 101 | - | if jsonOut { | |
| 102 | - | return writeJSON(out, map[string]string{ | |
| 103 | - | "warning": warning, | |
| 104 | - | "domain": domain, | |
| 105 | - | "status": "success", | |
| 106 | - | }) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | if writeErr := cliWriteLine(out, cliRenderWarning(warning)); writeErr != nil { | |
| 110 | - | return writeErr | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |
| 114 | - | } | |
D internal/adapters/in/cli/deploy_test.go +0 -119
| @@ -1,119 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 13 | - | "github.com/bnema/gordon/internal/domain" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | type deployTestDeployer struct { | |
| 17 | - | result *remote.DeployResult | |
| 18 | - | err error | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResult, error) { | |
| 22 | - | return d.result, d.err | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) { | |
| 26 | - | root := &domain.DeployFailureError{ | |
| 27 | - | Summary: "failed to deploy", | |
| 28 | - | Cause: "health check failed", | |
| 29 | - | Hint: "check DATABASE_URL", | |
| 30 | - | Logs: []string{"booting app", "connection refused"}, | |
| 31 | - | } | |
| 32 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false) | |
| 33 | - | ||
| 34 | - | require.Error(t, err) | |
| 35 | - | assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused") | |
| 36 | - | assert.ErrorIs(t, err, root) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func TestRunDeploy_StructuredRemoteNoFallbackReturnsFormattedError(t *testing.T) { | |
| 40 | - | originalSendDeploySignal := sendDeploySignal | |
| 41 | - | sendDeploySignal = func(string) (string, error) { | |
| 42 | - | t.Fatalf("sendDeploySignal should not be called for structured deploy failures") | |
| 43 | - | return "", nil | |
| 44 | - | } | |
| 45 | - | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 46 | - | ||
| 47 | - | root := &remote.HTTPError{ | |
| 48 | - | StatusCode: 503, | |
| 49 | - | Status: "503 Service Unavailable", | |
| 50 | - | Body: "failed to deploy", | |
| 51 | - | Cause: "health check failed", | |
| 52 | - | Hint: "check DATABASE_URL", | |
| 53 | - | Logs: []string{"booting app", "connection refused"}, | |
| 54 | - | Structured: true, | |
| 55 | - | } | |
| 56 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: root}, true, "app.example.com", &bytes.Buffer{}, false) | |
| 57 | - | ||
| 58 | - | require.Error(t, err) | |
| 59 | - | assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error()) | |
| 60 | - | assert.ErrorIs(t, err, root) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) { | |
| 64 | - | var out bytes.Buffer | |
| 65 | - | err := runDeploy(context.Background(), &deployTestDeployer{result: &remote.DeployResult{ | |
| 66 | - | Status: "deployed", | |
| 67 | - | Domain: "actual.example.com", | |
| 68 | - | ContainerID: "1234567890abcdef", | |
| 69 | - | }}, false, "requested.example.com", &out, false) | |
| 70 | - | ||
| 71 | - | require.NoError(t, err) | |
| 72 | - | assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)") | |
| 73 | - | assert.NotContains(t, out.String(), "requested.example.com") | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) { | |
| 77 | - | originalSendDeploySignal := sendDeploySignal | |
| 78 | - | sendDeploySignal = func(string) (string, error) { | |
| 79 | - | return "app.example.com", nil | |
| 80 | - | } | |
| 81 | - | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 82 | - | ||
| 83 | - | var out bytes.Buffer | |
| 84 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true) | |
| 85 | - | ||
| 86 | - | require.NoError(t, err) | |
| 87 | - | assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String()) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func TestHandleLocalDeployFallback_WrapsRemoteAndLocalErrors(t *testing.T) { | |
| 91 | - | originalSendDeploySignal := sendDeploySignal | |
| 92 | - | localErr := errors.New("local signal failed") | |
| 93 | - | sendDeploySignal = func(string) (string, error) { | |
| 94 | - | return "", localErr | |
| 95 | - | } | |
| 96 | - | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 97 | - | ||
| 98 | - | remoteErr := errors.New("remote deploy failed") | |
| 99 | - | err := handleLocalDeployFallback(remoteErr, "app.example.com", &bytes.Buffer{}, false) | |
| 100 | - | ||
| 101 | - | require.Error(t, err) | |
| 102 | - | assert.Equal(t, "failed to deploy: remote error: remote deploy failed; local fallback also failed: local signal failed", err.Error()) | |
| 103 | - | assert.ErrorIs(t, err, remoteErr) | |
| 104 | - | assert.ErrorIs(t, err, localErr) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | func TestRunDeploy_RemoteFallbackSanitizesWarning(t *testing.T) { | |
| 108 | - | originalSendDeploySignal := sendDeploySignal | |
| 109 | - | sendDeploySignal = func(string) (string, error) { | |
| 110 | - | return "app.example.com", nil | |
| 111 | - | } | |
| 112 | - | defer func() { sendDeploySignal = originalSendDeploySignal }() | |
| 113 | - | ||
| 114 | - | var out bytes.Buffer | |
| 115 | - | err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable:\n\x1b[31mRegistry Unavailable\x1b[0m")}, true, "app.example.com", &out, true) | |
| 116 | - | ||
| 117 | - | require.NoError(t, err) | |
| 118 | - | assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable:Registry Unavailable), used local signal fallback"}`, out.String()) | |
| 119 | - | } | |
M internal/adapters/in/cli/images.go +37 -39
| @@ -70,9 +70,7 @@ func newImagesCmd() *cobra.Command { | |||
| 70 | 70 | cmd := &cobra.Command{ | |
| 71 | 71 | Use: "images", | |
| 72 | 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.`, | |
| 73 | + | Long: `Inspect and clean up runtime and registry images through the selected daemon.`, | |
| 76 | 74 | } | |
| 77 | 75 | ||
| 78 | 76 | cmd.AddCommand(newImagesListCmd()) | |
| @@ -89,14 +87,10 @@ func newImagesListCmd() *cobra.Command { | |||
| 89 | 87 | Use: "list", | |
| 90 | 88 | Short: "List runtime images and registry tags", | |
| 91 | 89 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 92 | - | client, isRemote, err := GetRemoteClient() | |
| 90 | + | client, _, err := resolveDaemonClient() | |
| 93 | 91 | if err != nil { | |
| 94 | 92 | return err | |
| 95 | 93 | } | |
| 96 | - | if !isRemote { | |
| 97 | - | return fmt.Errorf("images commands require a configured remote target") | |
| 98 | - | } | |
| 99 | - | ||
| 100 | 94 | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) | |
| 101 | 95 | }, | |
| 102 | 96 | } | |
| @@ -115,21 +109,21 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 115 | 109 | Long: `Remove dangling runtime images and/or old registry tags. | |
| 116 | 110 | ||
| 117 | 111 | By default both runtime and registry cleanup run, keeping latest + 3 previous | |
| 118 | - | release tags per repository. Use --dangling or --registry to restrict scope.`, | |
| 112 | + | release tags per repository. Use --dangling or --registry to restrict scope. | |
| 113 | + | ||
| 114 | + | Only resources proven safe are deleted: every candidate gets an eligible, | |
| 115 | + | protected, or unknown verdict, and protected or unknown candidates are | |
| 116 | + | reported and left in place. A prune that deletes nothing succeeds.`, | |
| 119 | 117 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 120 | - | client, isRemote, err := GetRemoteClient() | |
| 118 | + | client, _, err := resolveDaemonClient() | |
| 121 | 119 | if err != nil { | |
| 122 | 120 | return err | |
| 123 | 121 | } | |
| 124 | - | if !isRemote { | |
| 125 | - | return fmt.Errorf("images commands require a configured remote target") | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 122 | return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout()) | |
| 129 | 123 | }, | |
| 130 | 124 | } | |
| 131 | 125 | ||
| 132 | - | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | |
| 126 | + | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Run the full inventory and planning, then report without deleting") | |
| 133 | 127 | cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast, | |
| 134 | 128 | "Number of previous non-latest tags to keep per repository (latest is always kept)") | |
| 135 | 129 | cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)") | |
| @@ -357,7 +351,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 357 | 351 | } | |
| 358 | 352 | } | |
| 359 | 353 | ||
| 360 | - | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) | |
| 354 | + | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, false) | |
| 361 | 355 | ||
| 362 | 356 | resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry) | |
| 363 | 357 | if err != nil { | |
| @@ -378,12 +372,14 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 378 | 372 | } | |
| 379 | 373 | ||
| 380 | 374 | if pruneRegistry { | |
| 381 | - | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed)) | |
| 375 | + | if err := cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed)); err != nil { | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil { | |
| 382 | 379 | return err | |
| 383 | 380 | } | |
| 384 | 381 | ||
| 385 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 386 | - | return err | |
| 382 | + | return cliRenderPruneSummary(out, resp.Plan) | |
| 387 | 383 | } | |
| 388 | 384 | ||
| 389 | 385 | func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error { | |
| @@ -391,33 +387,34 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP | |||
| 391 | 387 | return err | |
| 392 | 388 | } | |
| 393 | 389 | ||
| 394 | - | if pruneDangling { | |
| 395 | - | images, err := client.ListImages(ctx) | |
| 396 | - | if err != nil { | |
| 397 | - | return fmt.Errorf("failed to list images: %w", err) | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | danglingCount := 0 | |
| 401 | - | for _, img := range images { | |
| 402 | - | if img.Dangling { | |
| 403 | - | danglingCount++ | |
| 404 | - | } | |
| 405 | - | } | |
| 390 | + | // A dry run executes the full inventory and planning path: only the | |
| 391 | + | // deletion step is skipped, so the report is the executed plan. | |
| 392 | + | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry, true) | |
| 393 | + | resp, err := client.PruneImages(ctx, req) | |
| 394 | + | if err != nil { | |
| 395 | + | return fmt.Errorf("failed to plan image prune: %w", err) | |
| 396 | + | } | |
| 397 | + | if resp == nil { | |
| 398 | + | return fmt.Errorf("failed to plan image prune: empty response") | |
| 399 | + | } | |
| 406 | 400 | ||
| 407 | - | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 408 | - | return err | |
| 409 | - | } | |
| 410 | - | } else { | |
| 411 | - | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 401 | + | if pruneDangling { | |
| 402 | + | if err := cliWritef(out, "Runtime: would delete %d images\n", resp.Plan.CountByKind(domain.PruneResourceRuntimeImage)); err != nil { | |
| 412 | 403 | return err | |
| 413 | 404 | } | |
| 405 | + | } else if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 406 | + | return err | |
| 414 | 407 | } | |
| 415 | 408 | ||
| 416 | 409 | if pruneRegistry { | |
| 417 | - | return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases) | |
| 410 | + | if err := cliWritef(out, "Registry: latest + %d previous tags per repository\n", opts.KeepReleases); err != nil { | |
| 411 | + | return err | |
| 412 | + | } | |
| 413 | + | } else if err := cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")); err != nil { | |
| 414 | + | return err | |
| 418 | 415 | } | |
| 419 | 416 | ||
| 420 | - | return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 417 | + | return cliRenderPruneSummary(out, resp.Plan) | |
| 421 | 418 | } | |
| 422 | 419 | ||
| 423 | 420 | func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) { | |
| @@ -561,11 +558,12 @@ func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string] | |||
| 561 | 558 | return kept | |
| 562 | 559 | } | |
| 563 | 560 | ||
| 564 | - | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |
| 561 | + | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry, dryRun bool) dto.ImagePruneRequest { | |
| 565 | 562 | return dto.ImagePruneRequest{ | |
| 566 | 563 | KeepLast: &keepReleases, | |
| 567 | 564 | PruneDangling: &pruneDangling, | |
| 568 | 565 | PruneRegistry: &pruneRegistry, | |
| 566 | + | DryRun: &dryRun, | |
| 569 | 567 | } | |
| 570 | 568 | } | |
| 571 | 569 | ||
M internal/adapters/in/cli/images_test.go +35 -12
| @@ -297,7 +297,18 @@ func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) { | |||
| 297 | 297 | ||
| 298 | 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 +0 -5
| @@ -12,9 +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 | 15 | {"secrets list", newSecretsListCmd}, | |
| 19 | 16 | {"images list", newImagesListCmd}, | |
| 20 | 17 | {"backup list", newBackupListCmd}, | |
| @@ -23,9 +20,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 23 | 20 | {"backup volumes status", newVolumeBackupStatusCmd}, | |
| 24 | 21 | {"auth token list", newTokenListCmd}, | |
| 25 | 22 | {"remotes list", newRemotesListCmd}, | |
| 26 | - | {"pin list", newPinListCmd}, | |
| 27 | 23 | {"config show", newConfigShowCmd}, | |
| 28 | - | {"routes show", newRoutesShowCmd}, | |
| 29 | 24 | {"networks list", newNetworksListCmd}, | |
| 30 | 25 | {"images tags", newImagesTagsCmd}, | |
| 31 | 26 | {"tls status", newTLSStatusCmd}, | |
M internal/adapters/in/cli/json_test.go +6 -169
| @@ -14,24 +14,6 @@ import ( | |||
| 14 | 14 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | func TestRoutesList_JSONFlag_Accepted(t *testing.T) { | |
| 18 | - | cmd := newRoutesListCmd() | |
| 19 | - | f := cmd.Flags().Lookup("json") | |
| 20 | - | assert.NotNil(t, f) | |
| 21 | - | if f != nil { | |
| 22 | - | assert.Equal(t, "false", f.DefValue) | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) { | |
| 27 | - | cmd := newAttachmentsListCmd() | |
| 28 | - | f := cmd.Flags().Lookup("json") | |
| 29 | - | assert.NotNil(t, f) | |
| 30 | - | if f != nil { | |
| 31 | - | assert.Equal(t, "false", f.DefValue) | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | 17 | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |
| 36 | 18 | cmd := newSecretsListCmd() | |
| 37 | 19 | f := cmd.Flags().Lookup("json") | |
| @@ -77,15 +59,6 @@ func TestRemotesList_JSONFlag_Accepted(t *testing.T) { | |||
| 77 | 59 | } | |
| 78 | 60 | } | |
| 79 | 61 | ||
| 80 | - | func TestPinList_JSONFlag_Accepted(t *testing.T) { | |
| 81 | - | cmd := newPinListCmd() | |
| 82 | - | f := cmd.Flags().Lookup("json") | |
| 83 | - | assert.NotNil(t, f) | |
| 84 | - | if f != nil { | |
| 85 | - | assert.Equal(t, "false", f.DefValue) | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | ||
| 89 | 62 | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |
| 90 | 63 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 91 | 64 | images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}} | |
| @@ -113,7 +86,7 @@ func TestPinList_JSONShape_RoundTripsTags(t *testing.T) { | |||
| 113 | 86 | ||
| 114 | 87 | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |
| 115 | 88 | startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |
| 116 | - | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |
| 89 | + | jobs := []domain.BackupJob{{ID: "b1", App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |
| 117 | 90 | ||
| 118 | 91 | payload, err := json.Marshal(jobs) | |
| 119 | 92 | require.NoError(t, err) | |
| @@ -122,7 +95,9 @@ func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |||
| 122 | 95 | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 123 | 96 | require.Len(t, got, 1) | |
| 124 | 97 | assert.Equal(t, "b1", got[0].ID) | |
| 125 | - | assert.Equal(t, "app.example.com", got[0].Domain) | |
| 98 | + | assert.Equal(t, "shop", got[0].App) | |
| 99 | + | assert.Equal(t, "api", got[0].Service) | |
| 100 | + | assert.Equal(t, "orders", got[0].DBName) | |
| 126 | 101 | } | |
| 127 | 102 | ||
| 128 | 103 | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |
| @@ -172,157 +147,19 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||
| 172 | 147 | assert.True(t, got[0].InsecureTLS) | |
| 173 | 148 | } | |
| 174 | 149 | ||
| 175 | - | func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) { | |
| 176 | - | payload := struct { | |
| 177 | - | Target string `json:"target"` | |
| 178 | - | Images []string `json:"images"` | |
| 179 | - | }{ | |
| 180 | - | Target: "app.example.com", | |
| 181 | - | Images: []string{"postgres:16"}, | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | encoded, err := json.Marshal(payload) | |
| 185 | - | require.NoError(t, err) | |
| 186 | - | ||
| 187 | - | var got struct { | |
| 188 | - | Target string `json:"target"` | |
| 189 | - | Images []string `json:"images"` | |
| 190 | - | } | |
| 191 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 192 | - | assert.Equal(t, payload.Target, got.Target) | |
| 193 | - | assert.Equal(t, payload.Images, got.Images) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | 150 | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |
| 197 | - | payload := struct { | |
| 198 | - | Domain string `json:"domain"` | |
| 199 | - | Keys []string `json:"keys"` | |
| 200 | - | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 201 | - | }{ | |
| 151 | + | payload := remote.SecretsListResult{ | |
| 202 | 152 | Domain: "app.example.com", | |
| 203 | 153 | Keys: []string{"API_KEY"}, | |
| 204 | - | Attachments: []remote.AttachmentSecrets{{ | |
| 205 | - | Service: "postgres", | |
| 206 | - | Keys: []string{"POSTGRES_PASSWORD"}, | |
| 207 | - | }}, | |
| 208 | 154 | } | |
| 209 | 155 | ||
| 210 | 156 | encoded, err := json.Marshal(payload) | |
| 211 | 157 | require.NoError(t, err) | |
| 212 | 158 | ||
| 213 | - | var got struct { | |
| 214 | - | Domain string `json:"domain"` | |
| 215 | - | Keys []string `json:"keys"` | |
| 216 | - | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 217 | - | } | |
| 159 | + | var got remote.SecretsListResult | |
| 218 | 160 | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 219 | 161 | assert.Equal(t, payload.Domain, got.Domain) | |
| 220 | 162 | assert.Equal(t, payload.Keys, got.Keys) | |
| 221 | - | assert.Equal(t, payload.Attachments, got.Attachments) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) { | |
| 225 | - | payload := struct { | |
| 226 | - | Domain string `json:"domain"` | |
| 227 | - | Keys []string `json:"keys"` | |
| 228 | - | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 229 | - | }{ | |
| 230 | - | Domain: "app.example.com", | |
| 231 | - | Keys: []string{"API_KEY"}, | |
| 232 | - | Attachments: []remote.AttachmentSecrets{}, | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | encoded, err := json.Marshal(payload) | |
| 236 | - | require.NoError(t, err) | |
| 237 | - | assert.Contains(t, string(encoded), `"attachments":[]`) | |
| 238 | - | ||
| 239 | - | var got struct { | |
| 240 | - | Domain string `json:"domain"` | |
| 241 | - | Keys []string `json:"keys"` | |
| 242 | - | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 243 | - | } | |
| 244 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 245 | - | assert.NotNil(t, got.Attachments) | |
| 246 | - | assert.Empty(t, got.Attachments) | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) { | |
| 250 | - | payload := []struct { | |
| 251 | - | Domain string `json:"domain"` | |
| 252 | - | Image string `json:"image"` | |
| 253 | - | }{ | |
| 254 | - | {Domain: "app.example.com", Image: "app:latest"}, | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | encoded, err := json.Marshal(payload) | |
| 258 | - | require.NoError(t, err) | |
| 259 | - | ||
| 260 | - | var got []struct { | |
| 261 | - | Domain string `json:"domain"` | |
| 262 | - | Image string `json:"image"` | |
| 263 | - | } | |
| 264 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 265 | - | require.Len(t, got, 1) | |
| 266 | - | assert.Equal(t, payload[0], got[0]) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) { | |
| 270 | - | cmd := newRoutesStatusCmd() | |
| 271 | - | f := cmd.Flags().Lookup("json") | |
| 272 | - | assert.NotNil(t, f) | |
| 273 | - | if f != nil { | |
| 274 | - | assert.Equal(t, "false", f.DefValue) | |
| 275 | - | } | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) { | |
| 279 | - | payload := []routeListSection{{ | |
| 280 | - | Kind: "local", | |
| 281 | - | Name: "local", | |
| 282 | - | Routes: []routeListItem{{ | |
| 283 | - | Domain: "app.local", | |
| 284 | - | Image: "myapp:latest", | |
| 285 | - | }}, | |
| 286 | - | }} | |
| 287 | - | ||
| 288 | - | encoded, err := json.Marshal(payload) | |
| 289 | - | require.NoError(t, err) | |
| 290 | - | ||
| 291 | - | var got []routeListSection | |
| 292 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 293 | - | require.Len(t, got, 1) | |
| 294 | - | assert.Equal(t, "local", got[0].Kind) | |
| 295 | - | assert.Equal(t, "app.local", got[0].Routes[0].Domain) | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) { | |
| 299 | - | payload := []routeStatusSection{{ | |
| 300 | - | Kind: "remote", | |
| 301 | - | Name: "igor", | |
| 302 | - | URL: "https://gordon.supri.xyz", | |
| 303 | - | Routes: []routeStatusItem{{ | |
| 304 | - | Domain: "grafana.supri.xyz", | |
| 305 | - | Image: "grafana", | |
| 306 | - | ContainerStatus: "running", | |
| 307 | - | HTTPStatus: 200, | |
| 308 | - | Network: "gordon-shared", | |
| 309 | - | Attachments: []routeStatusAttachment{{ | |
| 310 | - | Name: "prometheus", | |
| 311 | - | Image: "prometheus:v5", | |
| 312 | - | Status: "running", | |
| 313 | - | }}, | |
| 314 | - | }}, | |
| 315 | - | }} | |
| 316 | - | ||
| 317 | - | encoded, err := json.Marshal(payload) | |
| 318 | - | require.NoError(t, err) | |
| 319 | - | ||
| 320 | - | var got []routeStatusSection | |
| 321 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 322 | - | require.Len(t, got, 1) | |
| 323 | - | assert.Equal(t, "igor", got[0].Name) | |
| 324 | - | assert.Equal(t, 200, got[0].Routes[0].HTTPStatus) | |
| 325 | - | assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name) | |
| 326 | 163 | } | |
| 327 | 164 | ||
| 328 | 165 | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |
M internal/adapters/in/cli/local.go +2 -2
| @@ -49,7 +49,7 @@ func (l *LocalServices) HasInternalTLS() bool { | |||
| 49 | 49 | ||
| 50 | 50 | // GetLocalServices creates local services for CLI operations. | |
| 51 | 51 | // It loads the config and initializes services without starting the server. | |
| 52 | - | func GetLocalServices(configPath string) (*LocalServices, error) { | |
| 52 | + | func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |
| 53 | 53 | // Set up viper with defaults | |
| 54 | 54 | v := viper.New() | |
| 55 | 55 | v.SetDefault("server.port", 8088) | |
| @@ -57,7 +57,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 57 | 57 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | |
| 58 | 58 | ||
| 59 | 59 | // Configure viper with config file paths | |
| 60 | - | app.ConfigureViper(v, configPath) | |
| 60 | + | app.ConfigureViper(v, cliConfigPath) | |
| 61 | 61 | ||
| 62 | 62 | // Read config file | |
| 63 | 63 | if err := v.ReadInConfig(); err != nil { | |
M internal/adapters/in/cli/mocks/mock_control_plane.go +570 -1398
| @@ -40,164 +40,55 @@ func (_m *MockControlPlane) EXPECT() *MockControlPlane_Expecter { | |||
| 40 | 40 | return &MockControlPlane_Expecter{mock: &_m.Mock} | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | // AddAttachment provides a mock function for the type MockControlPlane | |
| 44 | - | func (_mock *MockControlPlane) AddAttachment(ctx context.Context, domainOrGroup string, image string) error { | |
| 45 | - | ret := _mock.Called(ctx, domainOrGroup, image) | |
| 43 | + | // ApplyApp provides a mock function for the type MockControlPlane | |
| 44 | + | func (_mock *MockControlPlane) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) { | |
| 45 | + | ret := _mock.Called(ctx, req) | |
| 46 | 46 | ||
| 47 | 47 | if len(ret) == 0 { | |
| 48 | - | panic("no return value specified for AddAttachment") | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | var r0 error | |
| 52 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 53 | - | r0 = returnFunc(ctx, domainOrGroup, image) | |
| 54 | - | } else { | |
| 55 | - | r0 = ret.Error(0) | |
| 48 | + | panic("no return value specified for ApplyApp") | |
| 56 | 49 | } | |
| 57 | - | return r0 | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // MockControlPlane_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment' | |
| 61 | - | type MockControlPlane_AddAttachment_Call struct { | |
| 62 | - | *mock.Call | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // AddAttachment is a helper method to define mock.On call | |
| 66 | - | // - ctx context.Context | |
| 67 | - | // - domainOrGroup string | |
| 68 | - | // - image string | |
| 69 | - | func (_e *MockControlPlane_Expecter) AddAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_AddAttachment_Call { | |
| 70 | - | return &MockControlPlane_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)} | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func (_c *MockControlPlane_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockControlPlane_AddAttachment_Call { | |
| 74 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 75 | - | var arg0 context.Context | |
| 76 | - | if args[0] != nil { | |
| 77 | - | arg0 = args[0].(context.Context) | |
| 78 | - | } | |
| 79 | - | var arg1 string | |
| 80 | - | if args[1] != nil { | |
| 81 | - | arg1 = args[1].(string) | |
| 82 | - | } | |
| 83 | - | var arg2 string | |
| 84 | - | if args[2] != nil { | |
| 85 | - | arg2 = args[2].(string) | |
| 86 | - | } | |
| 87 | - | run( | |
| 88 | - | arg0, | |
| 89 | - | arg1, | |
| 90 | - | arg2, | |
| 91 | - | ) | |
| 92 | - | }) | |
| 93 | - | return _c | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | func (_c *MockControlPlane_AddAttachment_Call) Return(err error) *MockControlPlane_AddAttachment_Call { | |
| 97 | - | _c.Call.Return(err) | |
| 98 | - | return _c | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (_c *MockControlPlane_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockControlPlane_AddAttachment_Call { | |
| 102 | - | _c.Call.Return(run) | |
| 103 | - | return _c | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // AddAutoRouteAllowedDomain provides a mock function for the type MockControlPlane | |
| 107 | - | func (_mock *MockControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 108 | - | ret := _mock.Called(ctx, pattern) | |
| 109 | 50 | ||
| 110 | - | if len(ret) == 0 { | |
| 111 | - | panic("no return value specified for AddAutoRouteAllowedDomain") | |
| 51 | + | var r0 *dto.AppApplyResponse | |
| 52 | + | var r1 error | |
| 53 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.AppApplyRequest) (*dto.AppApplyResponse, error)); ok { | |
| 54 | + | return returnFunc(ctx, req) | |
| 112 | 55 | } | |
| 113 | - | ||
| 114 | - | var r0 error | |
| 115 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 116 | - | r0 = returnFunc(ctx, pattern) | |
| 56 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.AppApplyRequest) *dto.AppApplyResponse); ok { | |
| 57 | + | r0 = returnFunc(ctx, req) | |
| 117 | 58 | } else { | |
| 118 | - | r0 = ret.Error(0) | |
| 119 | - | } | |
| 120 | - | return r0 | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // MockControlPlane_AddAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAutoRouteAllowedDomain' | |
| 124 | - | type MockControlPlane_AddAutoRouteAllowedDomain_Call struct { | |
| 125 | - | *mock.Call | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // AddAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 129 | - | // - ctx context.Context | |
| 130 | - | // - pattern string | |
| 131 | - | func (_e *MockControlPlane_Expecter) AddAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_AddAutoRouteAllowedDomain_Call { | |
| 132 | - | return &MockControlPlane_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)} | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockControlPlane_AddAutoRouteAllowedDomain_Call { | |
| 136 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 137 | - | var arg0 context.Context | |
| 138 | - | if args[0] != nil { | |
| 139 | - | arg0 = args[0].(context.Context) | |
| 140 | - | } | |
| 141 | - | var arg1 string | |
| 142 | - | if args[1] != nil { | |
| 143 | - | arg1 = args[1].(string) | |
| 59 | + | if ret.Get(0) != nil { | |
| 60 | + | r0 = ret.Get(0).(*dto.AppApplyResponse) | |
| 144 | 61 | } | |
| 145 | - | run( | |
| 146 | - | arg0, | |
| 147 | - | arg1, | |
| 148 | - | ) | |
| 149 | - | }) | |
| 150 | - | return _c | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) Return(err error) *MockControlPlane_AddAutoRouteAllowedDomain_Call { | |
| 154 | - | _c.Call.Return(err) | |
| 155 | - | return _c | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func (_c *MockControlPlane_AddAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockControlPlane_AddAutoRouteAllowedDomain_Call { | |
| 159 | - | _c.Call.Return(run) | |
| 160 | - | return _c | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // AddRoute provides a mock function for the type MockControlPlane | |
| 164 | - | func (_mock *MockControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 165 | - | ret := _mock.Called(ctx, route) | |
| 166 | - | ||
| 167 | - | if len(ret) == 0 { | |
| 168 | - | panic("no return value specified for AddRoute") | |
| 169 | 62 | } | |
| 170 | - | ||
| 171 | - | var r0 error | |
| 172 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok { | |
| 173 | - | r0 = returnFunc(ctx, route) | |
| 63 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.AppApplyRequest) error); ok { | |
| 64 | + | r1 = returnFunc(ctx, req) | |
| 174 | 65 | } else { | |
| 175 | - | r0 = ret.Error(0) | |
| 66 | + | r1 = ret.Error(1) | |
| 176 | 67 | } | |
| 177 | - | return r0 | |
| 68 | + | return r0, r1 | |
| 178 | 69 | } | |
| 179 | 70 | ||
| 180 | - | // MockControlPlane_AddRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRoute' | |
| 181 | - | type MockControlPlane_AddRoute_Call struct { | |
| 71 | + | // MockControlPlane_ApplyApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyApp' | |
| 72 | + | type MockControlPlane_ApplyApp_Call struct { | |
| 182 | 73 | *mock.Call | |
| 183 | 74 | } | |
| 184 | 75 | ||
| 185 | - | // AddRoute is a helper method to define mock.On call | |
| 76 | + | // ApplyApp is a helper method to define mock.On call | |
| 186 | 77 | // - ctx context.Context | |
| 187 | - | // - route domain.Route | |
| 188 | - | func (_e *MockControlPlane_Expecter) AddRoute(ctx any, route any) *MockControlPlane_AddRoute_Call { | |
| 189 | - | return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)} | |
| 78 | + | // - req dto.AppApplyRequest | |
| 79 | + | func (_e *MockControlPlane_Expecter) ApplyApp(ctx any, req any) *MockControlPlane_ApplyApp_Call { | |
| 80 | + | return &MockControlPlane_ApplyApp_Call{Call: _e.mock.On("ApplyApp", ctx, req)} | |
| 190 | 81 | } | |
| 191 | 82 | ||
| 192 | - | func (_c *MockControlPlane_AddRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockControlPlane_AddRoute_Call { | |
| 83 | + | func (_c *MockControlPlane_ApplyApp_Call) Run(run func(ctx context.Context, req dto.AppApplyRequest)) *MockControlPlane_ApplyApp_Call { | |
| 193 | 84 | _c.Call.Run(func(args mock.Arguments) { | |
| 194 | 85 | var arg0 context.Context | |
| 195 | 86 | if args[0] != nil { | |
| 196 | 87 | arg0 = args[0].(context.Context) | |
| 197 | 88 | } | |
| 198 | - | var arg1 domain.Route | |
| 89 | + | var arg1 dto.AppApplyRequest | |
| 199 | 90 | if args[1] != nil { | |
| 200 | - | arg1 = args[1].(domain.Route) | |
| 91 | + | arg1 = args[1].(dto.AppApplyRequest) | |
| 201 | 92 | } | |
| 202 | 93 | run( | |
| 203 | 94 | arg0, | |
| @@ -207,12 +98,12 @@ func (_c *MockControlPlane_AddRoute_Call) Run(run func(ctx context.Context, rout | |||
| 207 | 98 | return _c | |
| 208 | 99 | } | |
| 209 | 100 | ||
| 210 | - | func (_c *MockControlPlane_AddRoute_Call) Return(err error) *MockControlPlane_AddRoute_Call { | |
| 211 | - | _c.Call.Return(err) | |
| 101 | + | func (_c *MockControlPlane_ApplyApp_Call) Return(appApplyResponse *dto.AppApplyResponse, err error) *MockControlPlane_ApplyApp_Call { | |
| 102 | + | _c.Call.Return(appApplyResponse, err) | |
| 212 | 103 | return _c | |
| 213 | 104 | } | |
| 214 | 105 | ||
| 215 | - | func (_c *MockControlPlane_AddRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockControlPlane_AddRoute_Call { | |
| 106 | + | func (_c *MockControlPlane_ApplyApp_Call) RunAndReturn(run func(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error)) *MockControlPlane_ApplyApp_Call { | |
| 216 | 107 | _c.Call.Return(run) | |
| 217 | 108 | return _c | |
| 218 | 109 | } | |
| @@ -279,180 +170,37 @@ func (_c *MockControlPlane_BackupStatus_Call) RunAndReturn(run func(ctx context. | |||
| 279 | 170 | return _c | |
| 280 | 171 | } | |
| 281 | 172 | ||
| 282 | - | // Bootstrap provides a mock function for the type MockControlPlane | |
| 283 | - | func (_mock *MockControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 284 | - | ret := _mock.Called(ctx, req) | |
| 285 | - | ||
| 286 | - | if len(ret) == 0 { | |
| 287 | - | panic("no return value specified for Bootstrap") | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | var r0 *dto.BootstrapResponse | |
| 291 | - | var r1 error | |
| 292 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error)); ok { | |
| 293 | - | return returnFunc(ctx, req) | |
| 294 | - | } | |
| 295 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.BootstrapRequest) *dto.BootstrapResponse); ok { | |
| 296 | - | r0 = returnFunc(ctx, req) | |
| 297 | - | } else { | |
| 298 | - | if ret.Get(0) != nil { | |
| 299 | - | r0 = ret.Get(0).(*dto.BootstrapResponse) | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.BootstrapRequest) error); ok { | |
| 303 | - | r1 = returnFunc(ctx, req) | |
| 304 | - | } else { | |
| 305 | - | r1 = ret.Error(1) | |
| 306 | - | } | |
| 307 | - | return r0, r1 | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | // MockControlPlane_Bootstrap_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bootstrap' | |
| 311 | - | type MockControlPlane_Bootstrap_Call struct { | |
| 312 | - | *mock.Call | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | // Bootstrap is a helper method to define mock.On call | |
| 316 | - | // - ctx context.Context | |
| 317 | - | // - req dto.BootstrapRequest | |
| 318 | - | func (_e *MockControlPlane_Expecter) Bootstrap(ctx any, req any) *MockControlPlane_Bootstrap_Call { | |
| 319 | - | return &MockControlPlane_Bootstrap_Call{Call: _e.mock.On("Bootstrap", ctx, req)} | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | func (_c *MockControlPlane_Bootstrap_Call) Run(run func(ctx context.Context, req dto.BootstrapRequest)) *MockControlPlane_Bootstrap_Call { | |
| 323 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 324 | - | var arg0 context.Context | |
| 325 | - | if args[0] != nil { | |
| 326 | - | arg0 = args[0].(context.Context) | |
| 327 | - | } | |
| 328 | - | var arg1 dto.BootstrapRequest | |
| 329 | - | if args[1] != nil { | |
| 330 | - | arg1 = args[1].(dto.BootstrapRequest) | |
| 331 | - | } | |
| 332 | - | run( | |
| 333 | - | arg0, | |
| 334 | - | arg1, | |
| 335 | - | ) | |
| 336 | - | }) | |
| 337 | - | return _c | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | func (_c *MockControlPlane_Bootstrap_Call) Return(bootstrapResponse *dto.BootstrapResponse, err error) *MockControlPlane_Bootstrap_Call { | |
| 341 | - | _c.Call.Return(bootstrapResponse, err) | |
| 342 | - | return _c | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | func (_c *MockControlPlane_Bootstrap_Call) RunAndReturn(run func(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)) *MockControlPlane_Bootstrap_Call { | |
| 346 | - | _c.Call.Return(run) | |
| 347 | - | return _c | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | // CleanupOrphanedAttachments provides a mock function for the type MockControlPlane | |
| 351 | - | func (_mock *MockControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 352 | - | ret := _mock.Called(ctx, owner, stop) | |
| 353 | - | ||
| 354 | - | if len(ret) == 0 { | |
| 355 | - | panic("no return value specified for CleanupOrphanedAttachments") | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | var r0 *domain.CleanupReport | |
| 359 | - | var r1 error | |
| 360 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*domain.CleanupReport, error)); ok { | |
| 361 | - | return returnFunc(ctx, owner, stop) | |
| 362 | - | } | |
| 363 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *domain.CleanupReport); ok { | |
| 364 | - | r0 = returnFunc(ctx, owner, stop) | |
| 365 | - | } else { | |
| 366 | - | if ret.Get(0) != nil { | |
| 367 | - | r0 = ret.Get(0).(*domain.CleanupReport) | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { | |
| 371 | - | r1 = returnFunc(ctx, owner, stop) | |
| 372 | - | } else { | |
| 373 | - | r1 = ret.Error(1) | |
| 374 | - | } | |
| 375 | - | return r0, r1 | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // MockControlPlane_CleanupOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupOrphanedAttachments' | |
| 379 | - | type MockControlPlane_CleanupOrphanedAttachments_Call struct { | |
| 380 | - | *mock.Call | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | // CleanupOrphanedAttachments is a helper method to define mock.On call | |
| 384 | - | // - ctx context.Context | |
| 385 | - | // - owner string | |
| 386 | - | // - stop bool | |
| 387 | - | func (_e *MockControlPlane_Expecter) CleanupOrphanedAttachments(ctx any, owner any, stop any) *MockControlPlane_CleanupOrphanedAttachments_Call { | |
| 388 | - | return &MockControlPlane_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)} | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) Run(run func(ctx context.Context, owner string, stop bool)) *MockControlPlane_CleanupOrphanedAttachments_Call { | |
| 392 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 393 | - | var arg0 context.Context | |
| 394 | - | if args[0] != nil { | |
| 395 | - | arg0 = args[0].(context.Context) | |
| 396 | - | } | |
| 397 | - | var arg1 string | |
| 398 | - | if args[1] != nil { | |
| 399 | - | arg1 = args[1].(string) | |
| 400 | - | } | |
| 401 | - | var arg2 bool | |
| 402 | - | if args[2] != nil { | |
| 403 | - | arg2 = args[2].(bool) | |
| 404 | - | } | |
| 405 | - | run( | |
| 406 | - | arg0, | |
| 407 | - | arg1, | |
| 408 | - | arg2, | |
| 409 | - | ) | |
| 410 | - | }) | |
| 411 | - | return _c | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockControlPlane_CleanupOrphanedAttachments_Call { | |
| 415 | - | _c.Call.Return(cleanupReport, err) | |
| 416 | - | return _c | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | func (_c *MockControlPlane_CleanupOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)) *MockControlPlane_CleanupOrphanedAttachments_Call { | |
| 420 | - | _c.Call.Return(run) | |
| 421 | - | return _c | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | // DeleteAttachmentSecret provides a mock function for the type MockControlPlane | |
| 425 | - | func (_mock *MockControlPlane) DeleteAttachmentSecret(ctx context.Context, domain1 string, service string, key string) error { | |
| 426 | - | ret := _mock.Called(ctx, domain1, service, key) | |
| 173 | + | // DeleteAppSecret provides a mock function for the type MockControlPlane | |
| 174 | + | func (_mock *MockControlPlane) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error { | |
| 175 | + | ret := _mock.Called(ctx, app, req) | |
| 427 | 176 | ||
| 428 | 177 | if len(ret) == 0 { | |
| 429 | - | panic("no return value specified for DeleteAttachmentSecret") | |
| 178 | + | panic("no return value specified for DeleteAppSecret") | |
| 430 | 179 | } | |
| 431 | 180 | ||
| 432 | 181 | var r0 error | |
| 433 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { | |
| 434 | - | r0 = returnFunc(ctx, domain1, service, key) | |
| 182 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretDeleteRequest) error); ok { | |
| 183 | + | r0 = returnFunc(ctx, app, req) | |
| 435 | 184 | } else { | |
| 436 | 185 | r0 = ret.Error(0) | |
| 437 | 186 | } | |
| 438 | 187 | return r0 | |
| 439 | 188 | } | |
| 440 | 189 | ||
| 441 | - | // MockControlPlane_DeleteAttachmentSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachmentSecret' | |
| 442 | - | type MockControlPlane_DeleteAttachmentSecret_Call struct { | |
| 190 | + | // MockControlPlane_DeleteAppSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAppSecret' | |
| 191 | + | type MockControlPlane_DeleteAppSecret_Call struct { | |
| 443 | 192 | *mock.Call | |
| 444 | 193 | } | |
| 445 | 194 | ||
| 446 | - | // DeleteAttachmentSecret is a helper method to define mock.On call | |
| 195 | + | // DeleteAppSecret is a helper method to define mock.On call | |
| 447 | 196 | // - ctx context.Context | |
| 448 | - | // - domain1 string | |
| 449 | - | // - service string | |
| 450 | - | // - key string | |
| 451 | - | func (_e *MockControlPlane_Expecter) DeleteAttachmentSecret(ctx any, domain1 any, service any, key any) *MockControlPlane_DeleteAttachmentSecret_Call { | |
| 452 | - | return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)} | |
| 197 | + | // - app string | |
| 198 | + | // - req dto.AppSecretDeleteRequest | |
| 199 | + | func (_e *MockControlPlane_Expecter) DeleteAppSecret(ctx any, app any, req any) *MockControlPlane_DeleteAppSecret_Call { | |
| 200 | + | return &MockControlPlane_DeleteAppSecret_Call{Call: _e.mock.On("DeleteAppSecret", ctx, app, req)} | |
| 453 | 201 | } | |
| 454 | 202 | ||
| 455 | - | func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Run(run func(ctx context.Context, domain1 string, service string, key string)) *MockControlPlane_DeleteAttachmentSecret_Call { | |
| 203 | + | func (_c *MockControlPlane_DeleteAppSecret_Call) Run(run func(ctx context.Context, app string, req dto.AppSecretDeleteRequest)) *MockControlPlane_DeleteAppSecret_Call { | |
| 456 | 204 | _c.Call.Run(func(args mock.Arguments) { | |
| 457 | 205 | var arg0 context.Context | |
| 458 | 206 | if args[0] != nil { | |
| @@ -462,30 +210,25 @@ func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Run(run func(ctx context | |||
| 462 | 210 | if args[1] != nil { | |
| 463 | 211 | arg1 = args[1].(string) | |
| 464 | 212 | } | |
| 465 | - | var arg2 string | |
| 213 | + | var arg2 dto.AppSecretDeleteRequest | |
| 466 | 214 | if args[2] != nil { | |
| 467 | - | arg2 = args[2].(string) | |
| 468 | - | } | |
| 469 | - | var arg3 string | |
| 470 | - | if args[3] != nil { | |
| 471 | - | arg3 = args[3].(string) | |
| 215 | + | arg2 = args[2].(dto.AppSecretDeleteRequest) | |
| 472 | 216 | } | |
| 473 | 217 | run( | |
| 474 | 218 | arg0, | |
| 475 | 219 | arg1, | |
| 476 | 220 | arg2, | |
| 477 | - | arg3, | |
| 478 | 221 | ) | |
| 479 | 222 | }) | |
| 480 | 223 | return _c | |
| 481 | 224 | } | |
| 482 | 225 | ||
| 483 | - | func (_c *MockControlPlane_DeleteAttachmentSecret_Call) Return(err error) *MockControlPlane_DeleteAttachmentSecret_Call { | |
| 226 | + | func (_c *MockControlPlane_DeleteAppSecret_Call) Return(err error) *MockControlPlane_DeleteAppSecret_Call { | |
| 484 | 227 | _c.Call.Return(err) | |
| 485 | 228 | return _c | |
| 486 | 229 | } | |
| 487 | 230 | ||
| 488 | - | func (_c *MockControlPlane_DeleteAttachmentSecret_Call) RunAndReturn(run func(ctx context.Context, domain1 string, service string, key string) error) *MockControlPlane_DeleteAttachmentSecret_Call { | |
| 231 | + | func (_c *MockControlPlane_DeleteAppSecret_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error) *MockControlPlane_DeleteAppSecret_Call { | |
| 489 | 232 | _c.Call.Return(run) | |
| 490 | 233 | return _c | |
| 491 | 234 | } | |
| @@ -553,631 +296,127 @@ func (_c *MockControlPlane_DeleteSecret_Call) RunAndReturn(run func(ctx context. | |||
| 553 | 296 | return _c | |
| 554 | 297 | } | |
| 555 | 298 | ||
| 556 | - | // Deploy provides a mock function for the type MockControlPlane | |
| 557 | - | func (_mock *MockControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 558 | - | ret := _mock.Called(ctx, deployDomain) | |
| 559 | - | ||
| 560 | - | if len(ret) == 0 { | |
| 561 | - | panic("no return value specified for Deploy") | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | var r0 *remote.DeployResult | |
| 565 | - | var r1 error | |
| 566 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*remote.DeployResult, error)); ok { | |
| 567 | - | return returnFunc(ctx, deployDomain) | |
| 568 | - | } | |
| 569 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *remote.DeployResult); ok { | |
| 570 | - | r0 = returnFunc(ctx, deployDomain) | |
| 571 | - | } else { | |
| 572 | - | if ret.Get(0) != nil { | |
| 573 | - | r0 = ret.Get(0).(*remote.DeployResult) | |
| 574 | - | } | |
| 575 | - | } | |
| 576 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 577 | - | r1 = returnFunc(ctx, deployDomain) | |
| 578 | - | } else { | |
| 579 | - | r1 = ret.Error(1) | |
| 580 | - | } | |
| 581 | - | return r0, r1 | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | // MockControlPlane_Deploy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deploy' | |
| 585 | - | type MockControlPlane_Deploy_Call struct { | |
| 586 | - | *mock.Call | |
| 587 | - | } | |
| 588 | - | ||
| 589 | - | // Deploy is a helper method to define mock.On call | |
| 590 | - | // - ctx context.Context | |
| 591 | - | // - deployDomain string | |
| 592 | - | func (_e *MockControlPlane_Expecter) Deploy(ctx any, deployDomain any) *MockControlPlane_Deploy_Call { | |
| 593 | - | return &MockControlPlane_Deploy_Call{Call: _e.mock.On("Deploy", ctx, deployDomain)} | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | func (_c *MockControlPlane_Deploy_Call) Run(run func(ctx context.Context, deployDomain string)) *MockControlPlane_Deploy_Call { | |
| 597 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 598 | - | var arg0 context.Context | |
| 599 | - | if args[0] != nil { | |
| 600 | - | arg0 = args[0].(context.Context) | |
| 601 | - | } | |
| 602 | - | var arg1 string | |
| 603 | - | if args[1] != nil { | |
| 604 | - | arg1 = args[1].(string) | |
| 605 | - | } | |
| 606 | - | run( | |
| 607 | - | arg0, | |
| 608 | - | arg1, | |
| 609 | - | ) | |
| 610 | - | }) | |
| 611 | - | return _c | |
| 612 | - | } | |
| 613 | - | ||
| 614 | - | func (_c *MockControlPlane_Deploy_Call) Return(deployResult *remote.DeployResult, err error) *MockControlPlane_Deploy_Call { | |
| 615 | - | _c.Call.Return(deployResult, err) | |
| 616 | - | return _c | |
| 617 | - | } | |
| 618 | - | ||
| 619 | - | func (_c *MockControlPlane_Deploy_Call) RunAndReturn(run func(ctx context.Context, deployDomain string) (*remote.DeployResult, error)) *MockControlPlane_Deploy_Call { | |
| 620 | - | _c.Call.Return(run) | |
| 621 | - | return _c | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | // DeployIntent provides a mock function for the type MockControlPlane | |
| 625 | - | func (_mock *MockControlPlane) DeployIntent(ctx context.Context, imageName string) error { | |
| 626 | - | ret := _mock.Called(ctx, imageName) | |
| 299 | + | // DeployApp provides a mock function for the type MockControlPlane | |
| 300 | + | func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | |
| 301 | + | ret := _mock.Called(ctx, app, req) | |
| 627 | 302 | ||
| 628 | 303 | if len(ret) == 0 { | |
| 629 | - | panic("no return value specified for DeployIntent") | |
| 304 | + | panic("no return value specified for DeployApp") | |
| 630 | 305 | } | |
| 631 | 306 | ||
| 632 | - | var r0 error | |
| 633 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 634 | - | r0 = returnFunc(ctx, imageName) | |
| 635 | - | } else { | |
| 636 | - | r0 = ret.Error(0) | |
| 307 | + | var r0 *dto.AppDeployResponse | |
| 308 | + | var r1 string | |
| 309 | + | var r2 error | |
| 310 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)); ok { | |
| 311 | + | return returnFunc(ctx, app, req) | |
| 637 | 312 | } | |
| 638 | - | return r0 | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | // MockControlPlane_DeployIntent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployIntent' | |
| 642 | - | type MockControlPlane_DeployIntent_Call struct { | |
| 643 | - | *mock.Call | |
| 644 | - | } | |
| 645 | - | ||
| 646 | - | // DeployIntent is a helper method to define mock.On call | |
| 647 | - | // - ctx context.Context | |
| 648 | - | // - imageName string | |
| 649 | - | func (_e *MockControlPlane_Expecter) DeployIntent(ctx any, imageName any) *MockControlPlane_DeployIntent_Call { | |
| 650 | - | return &MockControlPlane_DeployIntent_Call{Call: _e.mock.On("DeployIntent", ctx, imageName)} | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | func (_c *MockControlPlane_DeployIntent_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_DeployIntent_Call { | |
| 654 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 655 | - | var arg0 context.Context | |
| 656 | - | if args[0] != nil { | |
| 657 | - | arg0 = args[0].(context.Context) | |
| 658 | - | } | |
| 659 | - | var arg1 string | |
| 660 | - | if args[1] != nil { | |
| 661 | - | arg1 = args[1].(string) | |
| 662 | - | } | |
| 663 | - | run( | |
| 664 | - | arg0, | |
| 665 | - | arg1, | |
| 666 | - | ) | |
| 667 | - | }) | |
| 668 | - | return _c | |
| 669 | - | } | |
| 670 | - | ||
| 671 | - | func (_c *MockControlPlane_DeployIntent_Call) Return(err error) *MockControlPlane_DeployIntent_Call { | |
| 672 | - | _c.Call.Return(err) | |
| 673 | - | return _c | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | func (_c *MockControlPlane_DeployIntent_Call) RunAndReturn(run func(ctx context.Context, imageName string) error) *MockControlPlane_DeployIntent_Call { | |
| 677 | - | _c.Call.Return(run) | |
| 678 | - | return _c | |
| 679 | - | } | |
| 680 | - | ||
| 681 | - | // DetectDatabases provides a mock function for the type MockControlPlane | |
| 682 | - | func (_mock *MockControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 683 | - | ret := _mock.Called(ctx, backupDomain) | |
| 684 | - | ||
| 685 | - | if len(ret) == 0 { | |
| 686 | - | panic("no return value specified for DetectDatabases") | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | var r0 []dto.DatabaseInfo | |
| 690 | - | var r1 error | |
| 691 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.DatabaseInfo, error)); ok { | |
| 692 | - | return returnFunc(ctx, backupDomain) | |
| 693 | - | } | |
| 694 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.DatabaseInfo); ok { | |
| 695 | - | r0 = returnFunc(ctx, backupDomain) | |
| 696 | - | } else { | |
| 697 | - | if ret.Get(0) != nil { | |
| 698 | - | r0 = ret.Get(0).([]dto.DatabaseInfo) | |
| 699 | - | } | |
| 700 | - | } | |
| 701 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 702 | - | r1 = returnFunc(ctx, backupDomain) | |
| 703 | - | } else { | |
| 704 | - | r1 = ret.Error(1) | |
| 705 | - | } | |
| 706 | - | return r0, r1 | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | // MockControlPlane_DetectDatabases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectDatabases' | |
| 710 | - | type MockControlPlane_DetectDatabases_Call struct { | |
| 711 | - | *mock.Call | |
| 712 | - | } | |
| 713 | - | ||
| 714 | - | // DetectDatabases is a helper method to define mock.On call | |
| 715 | - | // - ctx context.Context | |
| 716 | - | // - backupDomain string | |
| 717 | - | func (_e *MockControlPlane_Expecter) DetectDatabases(ctx any, backupDomain any) *MockControlPlane_DetectDatabases_Call { | |
| 718 | - | return &MockControlPlane_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, backupDomain)} | |
| 719 | - | } | |
| 720 | - | ||
| 721 | - | func (_c *MockControlPlane_DetectDatabases_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_DetectDatabases_Call { | |
| 722 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 723 | - | var arg0 context.Context | |
| 724 | - | if args[0] != nil { | |
| 725 | - | arg0 = args[0].(context.Context) | |
| 726 | - | } | |
| 727 | - | var arg1 string | |
| 728 | - | if args[1] != nil { | |
| 729 | - | arg1 = args[1].(string) | |
| 730 | - | } | |
| 731 | - | run( | |
| 732 | - | arg0, | |
| 733 | - | arg1, | |
| 734 | - | ) | |
| 735 | - | }) | |
| 736 | - | return _c | |
| 737 | - | } | |
| 738 | - | ||
| 739 | - | func (_c *MockControlPlane_DetectDatabases_Call) Return(databaseInfos []dto.DatabaseInfo, err error) *MockControlPlane_DetectDatabases_Call { | |
| 740 | - | _c.Call.Return(databaseInfos, err) | |
| 741 | - | return _c | |
| 742 | - | } | |
| 743 | - | ||
| 744 | - | func (_c *MockControlPlane_DetectDatabases_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)) *MockControlPlane_DetectDatabases_Call { | |
| 745 | - | _c.Call.Return(run) | |
| 746 | - | return _c | |
| 747 | - | } | |
| 748 | - | ||
| 749 | - | // FindAttachmentTargetsByImage provides a mock function for the type MockControlPlane | |
| 750 | - | func (_mock *MockControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 751 | - | ret := _mock.Called(ctx, imageName) | |
| 752 | - | ||
| 753 | - | if len(ret) == 0 { | |
| 754 | - | panic("no return value specified for FindAttachmentTargetsByImage") | |
| 755 | - | } | |
| 756 | - | ||
| 757 | - | var r0 []string | |
| 758 | - | var r1 error | |
| 759 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |
| 760 | - | return returnFunc(ctx, imageName) | |
| 761 | - | } | |
| 762 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 763 | - | r0 = returnFunc(ctx, imageName) | |
| 764 | - | } else { | |
| 765 | - | if ret.Get(0) != nil { | |
| 766 | - | r0 = ret.Get(0).([]string) | |
| 767 | - | } | |
| 768 | - | } | |
| 769 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 770 | - | r1 = returnFunc(ctx, imageName) | |
| 771 | - | } else { | |
| 772 | - | r1 = ret.Error(1) | |
| 773 | - | } | |
| 774 | - | return r0, r1 | |
| 775 | - | } | |
| 776 | - | ||
| 777 | - | // MockControlPlane_FindAttachmentTargetsByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindAttachmentTargetsByImage' | |
| 778 | - | type MockControlPlane_FindAttachmentTargetsByImage_Call struct { | |
| 779 | - | *mock.Call | |
| 780 | - | } | |
| 781 | - | ||
| 782 | - | // FindAttachmentTargetsByImage is a helper method to define mock.On call | |
| 783 | - | // - ctx context.Context | |
| 784 | - | // - imageName string | |
| 785 | - | func (_e *MockControlPlane_Expecter) FindAttachmentTargetsByImage(ctx any, imageName any) *MockControlPlane_FindAttachmentTargetsByImage_Call { | |
| 786 | - | return &MockControlPlane_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)} | |
| 787 | - | } | |
| 788 | - | ||
| 789 | - | func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_FindAttachmentTargetsByImage_Call { | |
| 790 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 791 | - | var arg0 context.Context | |
| 792 | - | if args[0] != nil { | |
| 793 | - | arg0 = args[0].(context.Context) | |
| 794 | - | } | |
| 795 | - | var arg1 string | |
| 796 | - | if args[1] != nil { | |
| 797 | - | arg1 = args[1].(string) | |
| 798 | - | } | |
| 799 | - | run( | |
| 800 | - | arg0, | |
| 801 | - | arg1, | |
| 802 | - | ) | |
| 803 | - | }) | |
| 804 | - | return _c | |
| 805 | - | } | |
| 806 | - | ||
| 807 | - | func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) Return(strings []string, err error) *MockControlPlane_FindAttachmentTargetsByImage_Call { | |
| 808 | - | _c.Call.Return(strings, err) | |
| 809 | - | return _c | |
| 810 | - | } | |
| 811 | - | ||
| 812 | - | func (_c *MockControlPlane_FindAttachmentTargetsByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) ([]string, error)) *MockControlPlane_FindAttachmentTargetsByImage_Call { | |
| 813 | - | _c.Call.Return(run) | |
| 814 | - | return _c | |
| 815 | - | } | |
| 816 | - | ||
| 817 | - | // FindRoutesByImage provides a mock function for the type MockControlPlane | |
| 818 | - | func (_mock *MockControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 819 | - | ret := _mock.Called(ctx, imageName) | |
| 820 | - | ||
| 821 | - | if len(ret) == 0 { | |
| 822 | - | panic("no return value specified for FindRoutesByImage") | |
| 823 | - | } | |
| 824 | - | ||
| 825 | - | var r0 []domain.Route | |
| 826 | - | var r1 error | |
| 827 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.Route, error)); ok { | |
| 828 | - | return returnFunc(ctx, imageName) | |
| 829 | - | } | |
| 830 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok { | |
| 831 | - | r0 = returnFunc(ctx, imageName) | |
| 832 | - | } else { | |
| 833 | - | if ret.Get(0) != nil { | |
| 834 | - | r0 = ret.Get(0).([]domain.Route) | |
| 835 | - | } | |
| 836 | - | } | |
| 837 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 838 | - | r1 = returnFunc(ctx, imageName) | |
| 839 | - | } else { | |
| 840 | - | r1 = ret.Error(1) | |
| 841 | - | } | |
| 842 | - | return r0, r1 | |
| 843 | - | } | |
| 844 | - | ||
| 845 | - | // MockControlPlane_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage' | |
| 846 | - | type MockControlPlane_FindRoutesByImage_Call struct { | |
| 847 | - | *mock.Call | |
| 848 | - | } | |
| 849 | - | ||
| 850 | - | // FindRoutesByImage is a helper method to define mock.On call | |
| 851 | - | // - ctx context.Context | |
| 852 | - | // - imageName string | |
| 853 | - | func (_e *MockControlPlane_Expecter) FindRoutesByImage(ctx any, imageName any) *MockControlPlane_FindRoutesByImage_Call { | |
| 854 | - | return &MockControlPlane_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)} | |
| 855 | - | } | |
| 856 | - | ||
| 857 | - | func (_c *MockControlPlane_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockControlPlane_FindRoutesByImage_Call { | |
| 858 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 859 | - | var arg0 context.Context | |
| 860 | - | if args[0] != nil { | |
| 861 | - | arg0 = args[0].(context.Context) | |
| 862 | - | } | |
| 863 | - | var arg1 string | |
| 864 | - | if args[1] != nil { | |
| 865 | - | arg1 = args[1].(string) | |
| 866 | - | } | |
| 867 | - | run( | |
| 868 | - | arg0, | |
| 869 | - | arg1, | |
| 870 | - | ) | |
| 871 | - | }) | |
| 872 | - | return _c | |
| 873 | - | } | |
| 874 | - | ||
| 875 | - | func (_c *MockControlPlane_FindRoutesByImage_Call) Return(routes []domain.Route, err error) *MockControlPlane_FindRoutesByImage_Call { | |
| 876 | - | _c.Call.Return(routes, err) | |
| 877 | - | return _c | |
| 878 | - | } | |
| 879 | - | ||
| 880 | - | func (_c *MockControlPlane_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) ([]domain.Route, error)) *MockControlPlane_FindRoutesByImage_Call { | |
| 881 | - | _c.Call.Return(run) | |
| 882 | - | return _c | |
| 883 | - | } | |
| 884 | - | ||
| 885 | - | // GetAllAttachmentsConfig provides a mock function for the type MockControlPlane | |
| 886 | - | func (_mock *MockControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 887 | - | ret := _mock.Called(ctx) | |
| 888 | - | ||
| 889 | - | if len(ret) == 0 { | |
| 890 | - | panic("no return value specified for GetAllAttachmentsConfig") | |
| 891 | - | } | |
| 892 | - | ||
| 893 | - | var r0 map[string][]string | |
| 894 | - | var r1 error | |
| 895 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) (map[string][]string, error)); ok { | |
| 896 | - | return returnFunc(ctx) | |
| 897 | - | } | |
| 898 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok { | |
| 899 | - | r0 = returnFunc(ctx) | |
| 900 | - | } else { | |
| 901 | - | if ret.Get(0) != nil { | |
| 902 | - | r0 = ret.Get(0).(map[string][]string) | |
| 903 | - | } | |
| 904 | - | } | |
| 905 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 906 | - | r1 = returnFunc(ctx) | |
| 907 | - | } else { | |
| 908 | - | r1 = ret.Error(1) | |
| 909 | - | } | |
| 910 | - | return r0, r1 | |
| 911 | - | } | |
| 912 | - | ||
| 913 | - | // MockControlPlane_GetAllAttachmentsConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachmentsConfig' | |
| 914 | - | type MockControlPlane_GetAllAttachmentsConfig_Call struct { | |
| 915 | - | *mock.Call | |
| 916 | - | } | |
| 917 | - | ||
| 918 | - | // GetAllAttachmentsConfig is a helper method to define mock.On call | |
| 919 | - | // - ctx context.Context | |
| 920 | - | func (_e *MockControlPlane_Expecter) GetAllAttachmentsConfig(ctx any) *MockControlPlane_GetAllAttachmentsConfig_Call { | |
| 921 | - | return &MockControlPlane_GetAllAttachmentsConfig_Call{Call: _e.mock.On("GetAllAttachmentsConfig", ctx)} | |
| 922 | - | } | |
| 923 | - | ||
| 924 | - | func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetAllAttachmentsConfig_Call { | |
| 925 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 926 | - | var arg0 context.Context | |
| 927 | - | if args[0] != nil { | |
| 928 | - | arg0 = args[0].(context.Context) | |
| 929 | - | } | |
| 930 | - | run( | |
| 931 | - | arg0, | |
| 932 | - | ) | |
| 933 | - | }) | |
| 934 | - | return _c | |
| 935 | - | } | |
| 936 | - | ||
| 937 | - | func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) Return(stringToStrings map[string][]string, err error) *MockControlPlane_GetAllAttachmentsConfig_Call { | |
| 938 | - | _c.Call.Return(stringToStrings, err) | |
| 939 | - | return _c | |
| 940 | - | } | |
| 941 | - | ||
| 942 | - | func (_c *MockControlPlane_GetAllAttachmentsConfig_Call) RunAndReturn(run func(ctx context.Context) (map[string][]string, error)) *MockControlPlane_GetAllAttachmentsConfig_Call { | |
| 943 | - | _c.Call.Return(run) | |
| 944 | - | return _c | |
| 945 | - | } | |
| 946 | - | ||
| 947 | - | // GetAttachmentsConfig provides a mock function for the type MockControlPlane | |
| 948 | - | func (_mock *MockControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 949 | - | ret := _mock.Called(ctx, domainOrGroup) | |
| 950 | - | ||
| 951 | - | if len(ret) == 0 { | |
| 952 | - | panic("no return value specified for GetAttachmentsConfig") | |
| 953 | - | } | |
| 954 | - | ||
| 955 | - | var r0 []string | |
| 956 | - | var r1 error | |
| 957 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |
| 958 | - | return returnFunc(ctx, domainOrGroup) | |
| 959 | - | } | |
| 960 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 961 | - | r0 = returnFunc(ctx, domainOrGroup) | |
| 962 | - | } else { | |
| 963 | - | if ret.Get(0) != nil { | |
| 964 | - | r0 = ret.Get(0).([]string) | |
| 965 | - | } | |
| 966 | - | } | |
| 967 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 968 | - | r1 = returnFunc(ctx, domainOrGroup) | |
| 969 | - | } else { | |
| 970 | - | r1 = ret.Error(1) | |
| 971 | - | } | |
| 972 | - | return r0, r1 | |
| 973 | - | } | |
| 974 | - | ||
| 975 | - | // MockControlPlane_GetAttachmentsConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsConfig' | |
| 976 | - | type MockControlPlane_GetAttachmentsConfig_Call struct { | |
| 977 | - | *mock.Call | |
| 978 | - | } | |
| 979 | - | ||
| 980 | - | // GetAttachmentsConfig is a helper method to define mock.On call | |
| 981 | - | // - ctx context.Context | |
| 982 | - | // - domainOrGroup string | |
| 983 | - | func (_e *MockControlPlane_Expecter) GetAttachmentsConfig(ctx any, domainOrGroup any) *MockControlPlane_GetAttachmentsConfig_Call { | |
| 984 | - | return &MockControlPlane_GetAttachmentsConfig_Call{Call: _e.mock.On("GetAttachmentsConfig", ctx, domainOrGroup)} | |
| 985 | - | } | |
| 986 | - | ||
| 987 | - | func (_c *MockControlPlane_GetAttachmentsConfig_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockControlPlane_GetAttachmentsConfig_Call { | |
| 988 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 989 | - | var arg0 context.Context | |
| 990 | - | if args[0] != nil { | |
| 991 | - | arg0 = args[0].(context.Context) | |
| 992 | - | } | |
| 993 | - | var arg1 string | |
| 994 | - | if args[1] != nil { | |
| 995 | - | arg1 = args[1].(string) | |
| 996 | - | } | |
| 997 | - | run( | |
| 998 | - | arg0, | |
| 999 | - | arg1, | |
| 1000 | - | ) | |
| 1001 | - | }) | |
| 1002 | - | return _c | |
| 1003 | - | } | |
| 1004 | - | ||
| 1005 | - | func (_c *MockControlPlane_GetAttachmentsConfig_Call) Return(strings []string, err error) *MockControlPlane_GetAttachmentsConfig_Call { | |
| 1006 | - | _c.Call.Return(strings, err) | |
| 1007 | - | return _c | |
| 1008 | - | } | |
| 1009 | - | ||
| 1010 | - | func (_c *MockControlPlane_GetAttachmentsConfig_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockControlPlane_GetAttachmentsConfig_Call { | |
| 1011 | - | _c.Call.Return(run) | |
| 1012 | - | return _c | |
| 1013 | - | } | |
| 1014 | - | ||
| 1015 | - | // GetAutoRouteAllowedDomains provides a mock function for the type MockControlPlane | |
| 1016 | - | func (_mock *MockControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) { | |
| 1017 | - | ret := _mock.Called(ctx) | |
| 1018 | - | ||
| 1019 | - | if len(ret) == 0 { | |
| 1020 | - | panic("no return value specified for GetAutoRouteAllowedDomains") | |
| 1021 | - | } | |
| 1022 | - | ||
| 1023 | - | var r0 []string | |
| 1024 | - | var r1 error | |
| 1025 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { | |
| 1026 | - | return returnFunc(ctx) | |
| 1027 | - | } | |
| 1028 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { | |
| 1029 | - | r0 = returnFunc(ctx) | |
| 1030 | - | } else { | |
| 1031 | - | if ret.Get(0) != nil { | |
| 1032 | - | r0 = ret.Get(0).([]string) | |
| 1033 | - | } | |
| 1034 | - | } | |
| 1035 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1036 | - | r1 = returnFunc(ctx) | |
| 1037 | - | } else { | |
| 1038 | - | r1 = ret.Error(1) | |
| 1039 | - | } | |
| 1040 | - | return r0, r1 | |
| 1041 | - | } | |
| 1042 | - | ||
| 1043 | - | // MockControlPlane_GetAutoRouteAllowedDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAutoRouteAllowedDomains' | |
| 1044 | - | type MockControlPlane_GetAutoRouteAllowedDomains_Call struct { | |
| 1045 | - | *mock.Call | |
| 1046 | - | } | |
| 1047 | - | ||
| 1048 | - | // GetAutoRouteAllowedDomains is a helper method to define mock.On call | |
| 1049 | - | // - ctx context.Context | |
| 1050 | - | func (_e *MockControlPlane_Expecter) GetAutoRouteAllowedDomains(ctx any) *MockControlPlane_GetAutoRouteAllowedDomains_Call { | |
| 1051 | - | return &MockControlPlane_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)} | |
| 1052 | - | } | |
| 1053 | - | ||
| 1054 | - | func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetAutoRouteAllowedDomains_Call { | |
| 1055 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1056 | - | var arg0 context.Context | |
| 1057 | - | if args[0] != nil { | |
| 1058 | - | arg0 = args[0].(context.Context) | |
| 1059 | - | } | |
| 1060 | - | run( | |
| 1061 | - | arg0, | |
| 1062 | - | ) | |
| 1063 | - | }) | |
| 1064 | - | return _c | |
| 1065 | - | } | |
| 1066 | - | ||
| 1067 | - | func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) Return(strings []string, err error) *MockControlPlane_GetAutoRouteAllowedDomains_Call { | |
| 1068 | - | _c.Call.Return(strings, err) | |
| 1069 | - | return _c | |
| 1070 | - | } | |
| 1071 | - | ||
| 1072 | - | func (_c *MockControlPlane_GetAutoRouteAllowedDomains_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockControlPlane_GetAutoRouteAllowedDomains_Call { | |
| 1073 | - | _c.Call.Return(run) | |
| 1074 | - | return _c | |
| 1075 | - | } | |
| 1076 | - | ||
| 1077 | - | // GetConfig provides a mock function for the type MockControlPlane | |
| 1078 | - | func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | |
| 1079 | - | ret := _mock.Called(ctx) | |
| 1080 | - | ||
| 1081 | - | if len(ret) == 0 { | |
| 1082 | - | panic("no return value specified for GetConfig") | |
| 1083 | - | } | |
| 1084 | - | ||
| 1085 | - | var r0 *remote.Config | |
| 1086 | - | var r1 error | |
| 1087 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) (*remote.Config, error)); ok { | |
| 1088 | - | return returnFunc(ctx) | |
| 1089 | - | } | |
| 1090 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) *remote.Config); ok { | |
| 1091 | - | r0 = returnFunc(ctx) | |
| 313 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppDeployRequest) *dto.AppDeployResponse); ok { | |
| 314 | + | r0 = returnFunc(ctx, app, req) | |
| 1092 | 315 | } else { | |
| 1093 | 316 | if ret.Get(0) != nil { | |
| 1094 | - | r0 = ret.Get(0).(*remote.Config) | |
| 317 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 1095 | 318 | } | |
| 1096 | 319 | } | |
| 1097 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1098 | - | r1 = returnFunc(ctx) | |
| 320 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, dto.AppDeployRequest) string); ok { | |
| 321 | + | r1 = returnFunc(ctx, app, req) | |
| 1099 | 322 | } else { | |
| 1100 | - | r1 = ret.Error(1) | |
| 323 | + | r1 = ret.Get(1).(string) | |
| 1101 | 324 | } | |
| 1102 | - | return r0, r1 | |
| 325 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, dto.AppDeployRequest) error); ok { | |
| 326 | + | r2 = returnFunc(ctx, app, req) | |
| 327 | + | } else { | |
| 328 | + | r2 = ret.Error(2) | |
| 329 | + | } | |
| 330 | + | return r0, r1, r2 | |
| 1103 | 331 | } | |
| 1104 | 332 | ||
| 1105 | - | // MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig' | |
| 1106 | - | type MockControlPlane_GetConfig_Call struct { | |
| 333 | + | // MockControlPlane_DeployApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployApp' | |
| 334 | + | type MockControlPlane_DeployApp_Call struct { | |
| 1107 | 335 | *mock.Call | |
| 1108 | 336 | } | |
| 1109 | 337 | ||
| 1110 | - | // GetConfig is a helper method to define mock.On call | |
| 338 | + | // DeployApp is a helper method to define mock.On call | |
| 1111 | 339 | // - ctx context.Context | |
| 1112 | - | func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call { | |
| 1113 | - | return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)} | |
| 340 | + | // - app string | |
| 341 | + | // - req dto.AppDeployRequest | |
| 342 | + | func (_e *MockControlPlane_Expecter) DeployApp(ctx any, app any, req any) *MockControlPlane_DeployApp_Call { | |
| 343 | + | return &MockControlPlane_DeployApp_Call{Call: _e.mock.On("DeployApp", ctx, app, req)} | |
| 1114 | 344 | } | |
| 1115 | 345 | ||
| 1116 | - | func (_c *MockControlPlane_GetConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetConfig_Call { | |
| 346 | + | func (_c *MockControlPlane_DeployApp_Call) Run(run func(ctx context.Context, app string, req dto.AppDeployRequest)) *MockControlPlane_DeployApp_Call { | |
| 1117 | 347 | _c.Call.Run(func(args mock.Arguments) { | |
| 1118 | 348 | var arg0 context.Context | |
| 1119 | 349 | if args[0] != nil { | |
| 1120 | 350 | arg0 = args[0].(context.Context) | |
| 1121 | 351 | } | |
| 352 | + | var arg1 string | |
| 353 | + | if args[1] != nil { | |
| 354 | + | arg1 = args[1].(string) | |
| 355 | + | } | |
| 356 | + | var arg2 dto.AppDeployRequest | |
| 357 | + | if args[2] != nil { | |
| 358 | + | arg2 = args[2].(dto.AppDeployRequest) | |
| 359 | + | } | |
| 1122 | 360 | run( | |
| 1123 | 361 | arg0, | |
| 362 | + | arg1, | |
| 363 | + | arg2, | |
| 1124 | 364 | ) | |
| 1125 | 365 | }) | |
| 1126 | 366 | return _c | |
| 1127 | 367 | } | |
| 1128 | 368 | ||
| 1129 | - | func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call { | |
| 1130 | - | _c.Call.Return(config, err) | |
| 369 | + | func (_c *MockControlPlane_DeployApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_DeployApp_Call { | |
| 370 | + | _c.Call.Return(appDeployResponse, s, err) | |
| 1131 | 371 | return _c | |
| 1132 | 372 | } | |
| 1133 | 373 | ||
| 1134 | - | func (_c *MockControlPlane_GetConfig_Call) RunAndReturn(run func(ctx context.Context) (*remote.Config, error)) *MockControlPlane_GetConfig_Call { | |
| 374 | + | func (_c *MockControlPlane_DeployApp_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)) *MockControlPlane_DeployApp_Call { | |
| 1135 | 375 | _c.Call.Return(run) | |
| 1136 | 376 | return _c | |
| 1137 | 377 | } | |
| 1138 | 378 | ||
| 1139 | - | // GetContainerLogs provides a mock function for the type MockControlPlane | |
| 1140 | - | func (_mock *MockControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 1141 | - | ret := _mock.Called(ctx, logDomain, lines) | |
| 379 | + | // DiffApp provides a mock function for the type MockControlPlane | |
| 380 | + | func (_mock *MockControlPlane) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) { | |
| 381 | + | ret := _mock.Called(ctx, app) | |
| 1142 | 382 | ||
| 1143 | 383 | if len(ret) == 0 { | |
| 1144 | - | panic("no return value specified for GetContainerLogs") | |
| 384 | + | panic("no return value specified for DiffApp") | |
| 1145 | 385 | } | |
| 1146 | 386 | ||
| 1147 | - | var r0 []string | |
| 387 | + | var r0 *dto.AppDiffResponse | |
| 1148 | 388 | var r1 error | |
| 1149 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]string, error)); ok { | |
| 1150 | - | return returnFunc(ctx, logDomain, lines) | |
| 389 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDiffResponse, error)); ok { | |
| 390 | + | return returnFunc(ctx, app) | |
| 1151 | 391 | } | |
| 1152 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []string); ok { | |
| 1153 | - | r0 = returnFunc(ctx, logDomain, lines) | |
| 392 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDiffResponse); ok { | |
| 393 | + | r0 = returnFunc(ctx, app) | |
| 1154 | 394 | } else { | |
| 1155 | 395 | if ret.Get(0) != nil { | |
| 1156 | - | r0 = ret.Get(0).([]string) | |
| 396 | + | r0 = ret.Get(0).(*dto.AppDiffResponse) | |
| 1157 | 397 | } | |
| 1158 | 398 | } | |
| 1159 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok { | |
| 1160 | - | r1 = returnFunc(ctx, logDomain, lines) | |
| 399 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 400 | + | r1 = returnFunc(ctx, app) | |
| 1161 | 401 | } else { | |
| 1162 | 402 | r1 = ret.Error(1) | |
| 1163 | 403 | } | |
| 1164 | 404 | return r0, r1 | |
| 1165 | 405 | } | |
| 1166 | 406 | ||
| 1167 | - | // MockControlPlane_GetContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerLogs' | |
| 1168 | - | type MockControlPlane_GetContainerLogs_Call struct { | |
| 407 | + | // MockControlPlane_DiffApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiffApp' | |
| 408 | + | type MockControlPlane_DiffApp_Call struct { | |
| 1169 | 409 | *mock.Call | |
| 1170 | 410 | } | |
| 1171 | 411 | ||
| 1172 | - | // GetContainerLogs is a helper method to define mock.On call | |
| 412 | + | // DiffApp is a helper method to define mock.On call | |
| 1173 | 413 | // - ctx context.Context | |
| 1174 | - | // - logDomain string | |
| 1175 | - | // - lines int | |
| 1176 | - | func (_e *MockControlPlane_Expecter) GetContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_GetContainerLogs_Call { | |
| 1177 | - | return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)} | |
| 414 | + | // - app string | |
| 415 | + | func (_e *MockControlPlane_Expecter) DiffApp(ctx any, app any) *MockControlPlane_DiffApp_Call { | |
| 416 | + | return &MockControlPlane_DiffApp_Call{Call: _e.mock.On("DiffApp", ctx, app)} | |
| 1178 | 417 | } | |
| 1179 | 418 | ||
| 1180 | - | func (_c *MockControlPlane_GetContainerLogs_Call) Run(run func(ctx context.Context, logDomain string, lines int)) *MockControlPlane_GetContainerLogs_Call { | |
| 419 | + | func (_c *MockControlPlane_DiffApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_DiffApp_Call { | |
| 1181 | 420 | _c.Call.Run(func(args mock.Arguments) { | |
| 1182 | 421 | var arg0 context.Context | |
| 1183 | 422 | if args[0] != nil { | |
| @@ -1187,47 +426,42 @@ func (_c *MockControlPlane_GetContainerLogs_Call) Run(run func(ctx context.Conte | |||
| 1187 | 426 | if args[1] != nil { | |
| 1188 | 427 | arg1 = args[1].(string) | |
| 1189 | 428 | } | |
| 1190 | - | var arg2 int | |
| 1191 | - | if args[2] != nil { | |
| 1192 | - | arg2 = args[2].(int) | |
| 1193 | - | } | |
| 1194 | 429 | run( | |
| 1195 | 430 | arg0, | |
| 1196 | 431 | arg1, | |
| 1197 | - | arg2, | |
| 1198 | 432 | ) | |
| 1199 | 433 | }) | |
| 1200 | 434 | return _c | |
| 1201 | 435 | } | |
| 1202 | 436 | ||
| 1203 | - | func (_c *MockControlPlane_GetContainerLogs_Call) Return(strings []string, err error) *MockControlPlane_GetContainerLogs_Call { | |
| 1204 | - | _c.Call.Return(strings, err) | |
| 437 | + | func (_c *MockControlPlane_DiffApp_Call) Return(appDiffResponse *dto.AppDiffResponse, err error) *MockControlPlane_DiffApp_Call { | |
| 438 | + | _c.Call.Return(appDiffResponse, err) | |
| 1205 | 439 | return _c | |
| 1206 | 440 | } | |
| 1207 | 441 | ||
| 1208 | - | func (_c *MockControlPlane_GetContainerLogs_Call) RunAndReturn(run func(ctx context.Context, logDomain string, lines int) ([]string, error)) *MockControlPlane_GetContainerLogs_Call { | |
| 442 | + | func (_c *MockControlPlane_DiffApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDiffResponse, error)) *MockControlPlane_DiffApp_Call { | |
| 1209 | 443 | _c.Call.Return(run) | |
| 1210 | 444 | return _c | |
| 1211 | 445 | } | |
| 1212 | 446 | ||
| 1213 | - | // GetHealth provides a mock function for the type MockControlPlane | |
| 1214 | - | func (_mock *MockControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 447 | + | // GetConfig provides a mock function for the type MockControlPlane | |
| 448 | + | func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | |
| 1215 | 449 | ret := _mock.Called(ctx) | |
| 1216 | 450 | ||
| 1217 | 451 | if len(ret) == 0 { | |
| 1218 | - | panic("no return value specified for GetHealth") | |
| 452 | + | panic("no return value specified for GetConfig") | |
| 1219 | 453 | } | |
| 1220 | 454 | ||
| 1221 | - | var r0 map[string]*remote.RouteHealth | |
| 455 | + | var r0 *remote.Config | |
| 1222 | 456 | var r1 error | |
| 1223 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) (map[string]*remote.RouteHealth, error)); ok { | |
| 457 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*remote.Config, error)); ok { | |
| 1224 | 458 | return returnFunc(ctx) | |
| 1225 | 459 | } | |
| 1226 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*remote.RouteHealth); ok { | |
| 460 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *remote.Config); ok { | |
| 1227 | 461 | r0 = returnFunc(ctx) | |
| 1228 | 462 | } else { | |
| 1229 | 463 | if ret.Get(0) != nil { | |
| 1230 | - | r0 = ret.Get(0).(map[string]*remote.RouteHealth) | |
| 464 | + | r0 = ret.Get(0).(*remote.Config) | |
| 1231 | 465 | } | |
| 1232 | 466 | } | |
| 1233 | 467 | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| @@ -1238,18 +472,18 @@ func (_mock *MockControlPlane) GetHealth(ctx context.Context) (map[string]*remot | |||
| 1238 | 472 | return r0, r1 | |
| 1239 | 473 | } | |
| 1240 | 474 | ||
| 1241 | - | // MockControlPlane_GetHealth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHealth' | |
| 1242 | - | type MockControlPlane_GetHealth_Call struct { | |
| 475 | + | // MockControlPlane_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig' | |
| 476 | + | type MockControlPlane_GetConfig_Call struct { | |
| 1243 | 477 | *mock.Call | |
| 1244 | 478 | } | |
| 1245 | 479 | ||
| 1246 | - | // GetHealth is a helper method to define mock.On call | |
| 480 | + | // GetConfig is a helper method to define mock.On call | |
| 1247 | 481 | // - ctx context.Context | |
| 1248 | - | func (_e *MockControlPlane_Expecter) GetHealth(ctx any) *MockControlPlane_GetHealth_Call { | |
| 1249 | - | return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)} | |
| 482 | + | func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call { | |
| 483 | + | return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)} | |
| 1250 | 484 | } | |
| 1251 | 485 | ||
| 1252 | - | func (_c *MockControlPlane_GetHealth_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetHealth_Call { | |
| 486 | + | func (_c *MockControlPlane_GetConfig_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetConfig_Call { | |
| 1253 | 487 | _c.Call.Run(func(args mock.Arguments) { | |
| 1254 | 488 | var arg0 context.Context | |
| 1255 | 489 | if args[0] != nil { | |
| @@ -1262,12 +496,12 @@ func (_c *MockControlPlane_GetHealth_Call) Run(run func(ctx context.Context)) *M | |||
| 1262 | 496 | return _c | |
| 1263 | 497 | } | |
| 1264 | 498 | ||
| 1265 | - | func (_c *MockControlPlane_GetHealth_Call) Return(stringToRouteHealth map[string]*remote.RouteHealth, err error) *MockControlPlane_GetHealth_Call { | |
| 1266 | - | _c.Call.Return(stringToRouteHealth, err) | |
| 499 | + | func (_c *MockControlPlane_GetConfig_Call) Return(config *remote.Config, err error) *MockControlPlane_GetConfig_Call { | |
| 500 | + | _c.Call.Return(config, err) | |
| 1267 | 501 | return _c | |
| 1268 | 502 | } | |
| 1269 | 503 | ||
| 1270 | - | func (_c *MockControlPlane_GetHealth_Call) RunAndReturn(run func(ctx context.Context) (map[string]*remote.RouteHealth, error)) *MockControlPlane_GetHealth_Call { | |
| 504 | + | func (_c *MockControlPlane_GetConfig_Call) RunAndReturn(run func(ctx context.Context) (*remote.Config, error)) *MockControlPlane_GetConfig_Call { | |
| 1271 | 505 | _c.Call.Return(run) | |
| 1272 | 506 | return _c | |
| 1273 | 507 | } | |
| @@ -1340,74 +574,6 @@ func (_c *MockControlPlane_GetProcessLogs_Call) RunAndReturn(run func(ctx contex | |||
| 1340 | 574 | return _c | |
| 1341 | 575 | } | |
| 1342 | 576 | ||
| 1343 | - | // GetRoute provides a mock function for the type MockControlPlane | |
| 1344 | - | func (_mock *MockControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 1345 | - | ret := _mock.Called(ctx, routeDomain) | |
| 1346 | - | ||
| 1347 | - | if len(ret) == 0 { | |
| 1348 | - | panic("no return value specified for GetRoute") | |
| 1349 | - | } | |
| 1350 | - | ||
| 1351 | - | var r0 *domain.Route | |
| 1352 | - | var r1 error | |
| 1353 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.Route, error)); ok { | |
| 1354 | - | return returnFunc(ctx, routeDomain) | |
| 1355 | - | } | |
| 1356 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.Route); ok { | |
| 1357 | - | r0 = returnFunc(ctx, routeDomain) | |
| 1358 | - | } else { | |
| 1359 | - | if ret.Get(0) != nil { | |
| 1360 | - | r0 = ret.Get(0).(*domain.Route) | |
| 1361 | - | } | |
| 1362 | - | } | |
| 1363 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 1364 | - | r1 = returnFunc(ctx, routeDomain) | |
| 1365 | - | } else { | |
| 1366 | - | r1 = ret.Error(1) | |
| 1367 | - | } | |
| 1368 | - | return r0, r1 | |
| 1369 | - | } | |
| 1370 | - | ||
| 1371 | - | // MockControlPlane_GetRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoute' | |
| 1372 | - | type MockControlPlane_GetRoute_Call struct { | |
| 1373 | - | *mock.Call | |
| 1374 | - | } | |
| 1375 | - | ||
| 1376 | - | // GetRoute is a helper method to define mock.On call | |
| 1377 | - | // - ctx context.Context | |
| 1378 | - | // - routeDomain string | |
| 1379 | - | func (_e *MockControlPlane_Expecter) GetRoute(ctx any, routeDomain any) *MockControlPlane_GetRoute_Call { | |
| 1380 | - | return &MockControlPlane_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, routeDomain)} | |
| 1381 | - | } | |
| 1382 | - | ||
| 1383 | - | func (_c *MockControlPlane_GetRoute_Call) Run(run func(ctx context.Context, routeDomain string)) *MockControlPlane_GetRoute_Call { | |
| 1384 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1385 | - | var arg0 context.Context | |
| 1386 | - | if args[0] != nil { | |
| 1387 | - | arg0 = args[0].(context.Context) | |
| 1388 | - | } | |
| 1389 | - | var arg1 string | |
| 1390 | - | if args[1] != nil { | |
| 1391 | - | arg1 = args[1].(string) | |
| 1392 | - | } | |
| 1393 | - | run( | |
| 1394 | - | arg0, | |
| 1395 | - | arg1, | |
| 1396 | - | ) | |
| 1397 | - | }) | |
| 1398 | - | return _c | |
| 1399 | - | } | |
| 1400 | - | ||
| 1401 | - | func (_c *MockControlPlane_GetRoute_Call) Return(route *domain.Route, err error) *MockControlPlane_GetRoute_Call { | |
| 1402 | - | _c.Call.Return(route, err) | |
| 1403 | - | return _c | |
| 1404 | - | } | |
| 1405 | - | ||
| 1406 | - | func (_c *MockControlPlane_GetRoute_Call) RunAndReturn(run func(ctx context.Context, routeDomain string) (*domain.Route, error)) *MockControlPlane_GetRoute_Call { | |
| 1407 | - | _c.Call.Return(run) | |
| 1408 | - | return _c | |
| 1409 | - | } | |
| 1410 | - | ||
| 1411 | 577 | // GetStatus provides a mock function for the type MockControlPlane | |
| 1412 | 578 | func (_mock *MockControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 1413 | 579 | ret := _mock.Called(ctx) | |
| @@ -1594,92 +760,24 @@ func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx cont | |||
| 1594 | 760 | return _c | |
| 1595 | 761 | } | |
| 1596 | 762 | ||
| 1597 | - | // ListBackups provides a mock function for the type MockControlPlane | |
| 1598 | - | func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 1599 | - | ret := _mock.Called(ctx, backupDomain) | |
| 1600 | - | ||
| 1601 | - | if len(ret) == 0 { | |
| 1602 | - | panic("no return value specified for ListBackups") | |
| 1603 | - | } | |
| 1604 | - | ||
| 1605 | - | var r0 []dto.BackupJob | |
| 1606 | - | var r1 error | |
| 1607 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.BackupJob, error)); ok { | |
| 1608 | - | return returnFunc(ctx, backupDomain) | |
| 1609 | - | } | |
| 1610 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.BackupJob); ok { | |
| 1611 | - | r0 = returnFunc(ctx, backupDomain) | |
| 1612 | - | } else { | |
| 1613 | - | if ret.Get(0) != nil { | |
| 1614 | - | r0 = ret.Get(0).([]dto.BackupJob) | |
| 1615 | - | } | |
| 1616 | - | } | |
| 1617 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 1618 | - | r1 = returnFunc(ctx, backupDomain) | |
| 1619 | - | } else { | |
| 1620 | - | r1 = ret.Error(1) | |
| 1621 | - | } | |
| 1622 | - | return r0, r1 | |
| 1623 | - | } | |
| 1624 | - | ||
| 1625 | - | // MockControlPlane_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups' | |
| 1626 | - | type MockControlPlane_ListBackups_Call struct { | |
| 1627 | - | *mock.Call | |
| 1628 | - | } | |
| 1629 | - | ||
| 1630 | - | // ListBackups is a helper method to define mock.On call | |
| 1631 | - | // - ctx context.Context | |
| 1632 | - | // - backupDomain string | |
| 1633 | - | func (_e *MockControlPlane_Expecter) ListBackups(ctx any, backupDomain any) *MockControlPlane_ListBackups_Call { | |
| 1634 | - | return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, backupDomain)} | |
| 1635 | - | } | |
| 1636 | - | ||
| 1637 | - | func (_c *MockControlPlane_ListBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListBackups_Call { | |
| 1638 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1639 | - | var arg0 context.Context | |
| 1640 | - | if args[0] != nil { | |
| 1641 | - | arg0 = args[0].(context.Context) | |
| 1642 | - | } | |
| 1643 | - | var arg1 string | |
| 1644 | - | if args[1] != nil { | |
| 1645 | - | arg1 = args[1].(string) | |
| 1646 | - | } | |
| 1647 | - | run( | |
| 1648 | - | arg0, | |
| 1649 | - | arg1, | |
| 1650 | - | ) | |
| 1651 | - | }) | |
| 1652 | - | return _c | |
| 1653 | - | } | |
| 1654 | - | ||
| 1655 | - | func (_c *MockControlPlane_ListBackups_Call) Return(backupJobs []dto.BackupJob, err error) *MockControlPlane_ListBackups_Call { | |
| 1656 | - | _c.Call.Return(backupJobs, err) | |
| 1657 | - | return _c | |
| 1658 | - | } | |
| 1659 | - | ||
| 1660 | - | func (_c *MockControlPlane_ListBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)) *MockControlPlane_ListBackups_Call { | |
| 1661 | - | _c.Call.Return(run) | |
| 1662 | - | return _c | |
| 1663 | - | } | |
| 1664 | - | ||
| 1665 | - | // ListNetworks provides a mock function for the type MockControlPlane | |
| 1666 | - | func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 763 | + | // ListApps provides a mock function for the type MockControlPlane | |
| 764 | + | func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) { | |
| 1667 | 765 | ret := _mock.Called(ctx) | |
| 1668 | 766 | ||
| 1669 | 767 | if len(ret) == 0 { | |
| 1670 | - | panic("no return value specified for ListNetworks") | |
| 768 | + | panic("no return value specified for ListApps") | |
| 1671 | 769 | } | |
| 1672 | 770 | ||
| 1673 | - | var r0 []*domain.NetworkInfo | |
| 771 | + | var r0 []dto.AppSummaryDTO | |
| 1674 | 772 | var r1 error | |
| 1675 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok { | |
| 773 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]dto.AppSummaryDTO, error)); ok { | |
| 1676 | 774 | return returnFunc(ctx) | |
| 1677 | 775 | } | |
| 1678 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok { | |
| 776 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []dto.AppSummaryDTO); ok { | |
| 1679 | 777 | r0 = returnFunc(ctx) | |
| 1680 | 778 | } else { | |
| 1681 | 779 | if ret.Get(0) != nil { | |
| 1682 | - | r0 = ret.Get(0).([]*domain.NetworkInfo) | |
| 780 | + | r0 = ret.Get(0).([]dto.AppSummaryDTO) | |
| 1683 | 781 | } | |
| 1684 | 782 | } | |
| 1685 | 783 | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| @@ -1690,18 +788,18 @@ func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.Netw | |||
| 1690 | 788 | return r0, r1 | |
| 1691 | 789 | } | |
| 1692 | 790 | ||
| 1693 | - | // MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks' | |
| 1694 | - | type MockControlPlane_ListNetworks_Call struct { | |
| 791 | + | // MockControlPlane_ListApps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListApps' | |
| 792 | + | type MockControlPlane_ListApps_Call struct { | |
| 1695 | 793 | *mock.Call | |
| 1696 | 794 | } | |
| 1697 | 795 | ||
| 1698 | - | // ListNetworks is a helper method to define mock.On call | |
| 796 | + | // ListApps is a helper method to define mock.On call | |
| 1699 | 797 | // - ctx context.Context | |
| 1700 | - | func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call { | |
| 1701 | - | return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)} | |
| 798 | + | func (_e *MockControlPlane_Expecter) ListApps(ctx any) *MockControlPlane_ListApps_Call { | |
| 799 | + | return &MockControlPlane_ListApps_Call{Call: _e.mock.On("ListApps", ctx)} | |
| 1702 | 800 | } | |
| 1703 | 801 | ||
| 1704 | - | func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListNetworks_Call { | |
| 802 | + | func (_c *MockControlPlane_ListApps_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListApps_Call { | |
| 1705 | 803 | _c.Call.Run(func(args mock.Arguments) { | |
| 1706 | 804 | var arg0 context.Context | |
| 1707 | 805 | if args[0] != nil { | |
| @@ -1714,96 +812,102 @@ func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context)) | |||
| 1714 | 812 | return _c | |
| 1715 | 813 | } | |
| 1716 | 814 | ||
| 1717 | - | func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call { | |
| 1718 | - | _c.Call.Return(networkInfos, err) | |
| 815 | + | func (_c *MockControlPlane_ListApps_Call) Return(appSummaryDTOs []dto.AppSummaryDTO, err error) *MockControlPlane_ListApps_Call { | |
| 816 | + | _c.Call.Return(appSummaryDTOs, err) | |
| 1719 | 817 | return _c | |
| 1720 | 818 | } | |
| 1721 | 819 | ||
| 1722 | - | func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockControlPlane_ListNetworks_Call { | |
| 820 | + | func (_c *MockControlPlane_ListApps_Call) RunAndReturn(run func(ctx context.Context) ([]dto.AppSummaryDTO, error)) *MockControlPlane_ListApps_Call { | |
| 1723 | 821 | _c.Call.Return(run) | |
| 1724 | 822 | return _c | |
| 1725 | 823 | } | |
| 1726 | 824 | ||
| 1727 | - | // ListOrphanedAttachments provides a mock function for the type MockControlPlane | |
| 1728 | - | func (_mock *MockControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 1729 | - | ret := _mock.Called(ctx) | |
| 825 | + | // ListBackups provides a mock function for the type MockControlPlane | |
| 826 | + | func (_mock *MockControlPlane) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) { | |
| 827 | + | ret := _mock.Called(ctx, app) | |
| 1730 | 828 | ||
| 1731 | 829 | if len(ret) == 0 { | |
| 1732 | - | panic("no return value specified for ListOrphanedAttachments") | |
| 830 | + | panic("no return value specified for ListBackups") | |
| 1733 | 831 | } | |
| 1734 | 832 | ||
| 1735 | - | var r0 []domain.CleanupAttachment | |
| 833 | + | var r0 []dto.BackupJob | |
| 1736 | 834 | var r1 error | |
| 1737 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CleanupAttachment, error)); ok { | |
| 1738 | - | return returnFunc(ctx) | |
| 835 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.BackupJob, error)); ok { | |
| 836 | + | return returnFunc(ctx, app) | |
| 1739 | 837 | } | |
| 1740 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CleanupAttachment); ok { | |
| 1741 | - | r0 = returnFunc(ctx) | |
| 838 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.BackupJob); ok { | |
| 839 | + | r0 = returnFunc(ctx, app) | |
| 1742 | 840 | } else { | |
| 1743 | 841 | if ret.Get(0) != nil { | |
| 1744 | - | r0 = ret.Get(0).([]domain.CleanupAttachment) | |
| 842 | + | r0 = ret.Get(0).([]dto.BackupJob) | |
| 1745 | 843 | } | |
| 1746 | 844 | } | |
| 1747 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1748 | - | r1 = returnFunc(ctx) | |
| 845 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 846 | + | r1 = returnFunc(ctx, app) | |
| 1749 | 847 | } else { | |
| 1750 | 848 | r1 = ret.Error(1) | |
| 1751 | 849 | } | |
| 1752 | 850 | return r0, r1 | |
| 1753 | 851 | } | |
| 1754 | 852 | ||
| 1755 | - | // MockControlPlane_ListOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrphanedAttachments' | |
| 1756 | - | type MockControlPlane_ListOrphanedAttachments_Call struct { | |
| 853 | + | // MockControlPlane_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups' | |
| 854 | + | type MockControlPlane_ListBackups_Call struct { | |
| 1757 | 855 | *mock.Call | |
| 1758 | 856 | } | |
| 1759 | 857 | ||
| 1760 | - | // ListOrphanedAttachments is a helper method to define mock.On call | |
| 858 | + | // ListBackups is a helper method to define mock.On call | |
| 1761 | 859 | // - ctx context.Context | |
| 1762 | - | func (_e *MockControlPlane_Expecter) ListOrphanedAttachments(ctx any) *MockControlPlane_ListOrphanedAttachments_Call { | |
| 1763 | - | return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)} | |
| 860 | + | // - app string | |
| 861 | + | func (_e *MockControlPlane_Expecter) ListBackups(ctx any, app any) *MockControlPlane_ListBackups_Call { | |
| 862 | + | return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, app)} | |
| 1764 | 863 | } | |
| 1765 | 864 | ||
| 1766 | - | func (_c *MockControlPlane_ListOrphanedAttachments_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListOrphanedAttachments_Call { | |
| 865 | + | func (_c *MockControlPlane_ListBackups_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ListBackups_Call { | |
| 1767 | 866 | _c.Call.Run(func(args mock.Arguments) { | |
| 1768 | 867 | var arg0 context.Context | |
| 1769 | 868 | if args[0] != nil { | |
| 1770 | 869 | arg0 = args[0].(context.Context) | |
| 1771 | 870 | } | |
| 871 | + | var arg1 string | |
| 872 | + | if args[1] != nil { | |
| 873 | + | arg1 = args[1].(string) | |
| 874 | + | } | |
| 1772 | 875 | run( | |
| 1773 | 876 | arg0, | |
| 877 | + | arg1, | |
| 1774 | 878 | ) | |
| 1775 | 879 | }) | |
| 1776 | 880 | return _c | |
| 1777 | 881 | } | |
| 1778 | 882 | ||
| 1779 | - | func (_c *MockControlPlane_ListOrphanedAttachments_Call) Return(cleanupAttachments []domain.CleanupAttachment, err error) *MockControlPlane_ListOrphanedAttachments_Call { | |
| 1780 | - | _c.Call.Return(cleanupAttachments, err) | |
| 883 | + | func (_c *MockControlPlane_ListBackups_Call) Return(backupJobs []dto.BackupJob, err error) *MockControlPlane_ListBackups_Call { | |
| 884 | + | _c.Call.Return(backupJobs, err) | |
| 1781 | 885 | return _c | |
| 1782 | 886 | } | |
| 1783 | 887 | ||
| 1784 | - | func (_c *MockControlPlane_ListOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CleanupAttachment, error)) *MockControlPlane_ListOrphanedAttachments_Call { | |
| 888 | + | func (_c *MockControlPlane_ListBackups_Call) RunAndReturn(run func(ctx context.Context, app string) ([]dto.BackupJob, error)) *MockControlPlane_ListBackups_Call { | |
| 1785 | 889 | _c.Call.Return(run) | |
| 1786 | 890 | return _c | |
| 1787 | 891 | } | |
| 1788 | 892 | ||
| 1789 | - | // ListRoutesWithDetails provides a mock function for the type MockControlPlane | |
| 1790 | - | func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 893 | + | // ListNetworks provides a mock function for the type MockControlPlane | |
| 894 | + | func (_mock *MockControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 1791 | 895 | ret := _mock.Called(ctx) | |
| 1792 | 896 | ||
| 1793 | 897 | if len(ret) == 0 { | |
| 1794 | - | panic("no return value specified for ListRoutesWithDetails") | |
| 898 | + | panic("no return value specified for ListNetworks") | |
| 1795 | 899 | } | |
| 1796 | 900 | ||
| 1797 | - | var r0 []remote.RouteInfo | |
| 901 | + | var r0 []*domain.NetworkInfo | |
| 1798 | 902 | var r1 error | |
| 1799 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]remote.RouteInfo, error)); ok { | |
| 903 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok { | |
| 1800 | 904 | return returnFunc(ctx) | |
| 1801 | 905 | } | |
| 1802 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []remote.RouteInfo); ok { | |
| 906 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok { | |
| 1803 | 907 | r0 = returnFunc(ctx) | |
| 1804 | 908 | } else { | |
| 1805 | 909 | if ret.Get(0) != nil { | |
| 1806 | - | r0 = ret.Get(0).([]remote.RouteInfo) | |
| 910 | + | r0 = ret.Get(0).([]*domain.NetworkInfo) | |
| 1807 | 911 | } | |
| 1808 | 912 | } | |
| 1809 | 913 | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| @@ -1814,18 +918,18 @@ func (_mock *MockControlPlane) ListRoutesWithDetails(ctx context.Context) ([]rem | |||
| 1814 | 918 | return r0, r1 | |
| 1815 | 919 | } | |
| 1816 | 920 | ||
| 1817 | - | // MockControlPlane_ListRoutesWithDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRoutesWithDetails' | |
| 1818 | - | type MockControlPlane_ListRoutesWithDetails_Call struct { | |
| 921 | + | // MockControlPlane_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks' | |
| 922 | + | type MockControlPlane_ListNetworks_Call struct { | |
| 1819 | 923 | *mock.Call | |
| 1820 | 924 | } | |
| 1821 | 925 | ||
| 1822 | - | // ListRoutesWithDetails is a helper method to define mock.On call | |
| 926 | + | // ListNetworks is a helper method to define mock.On call | |
| 1823 | 927 | // - ctx context.Context | |
| 1824 | - | func (_e *MockControlPlane_Expecter) ListRoutesWithDetails(ctx any) *MockControlPlane_ListRoutesWithDetails_Call { | |
| 1825 | - | return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)} | |
| 928 | + | func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call { | |
| 929 | + | return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)} | |
| 1826 | 930 | } | |
| 1827 | 931 | ||
| 1828 | - | func (_c *MockControlPlane_ListRoutesWithDetails_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListRoutesWithDetails_Call { | |
| 932 | + | func (_c *MockControlPlane_ListNetworks_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListNetworks_Call { | |
| 1829 | 933 | _c.Call.Run(func(args mock.Arguments) { | |
| 1830 | 934 | var arg0 context.Context | |
| 1831 | 935 | if args[0] != nil { | |
| @@ -1838,22 +942,22 @@ func (_c *MockControlPlane_ListRoutesWithDetails_Call) Run(run func(ctx context. | |||
| 1838 | 942 | return _c | |
| 1839 | 943 | } | |
| 1840 | 944 | ||
| 1841 | - | func (_c *MockControlPlane_ListRoutesWithDetails_Call) Return(vs []remote.RouteInfo, err error) *MockControlPlane_ListRoutesWithDetails_Call { | |
| 1842 | - | _c.Call.Return(vs, err) | |
| 945 | + | func (_c *MockControlPlane_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockControlPlane_ListNetworks_Call { | |
| 946 | + | _c.Call.Return(networkInfos, err) | |
| 1843 | 947 | return _c | |
| 1844 | 948 | } | |
| 1845 | 949 | ||
| 1846 | - | func (_c *MockControlPlane_ListRoutesWithDetails_Call) RunAndReturn(run func(ctx context.Context) ([]remote.RouteInfo, error)) *MockControlPlane_ListRoutesWithDetails_Call { | |
| 950 | + | func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockControlPlane_ListNetworks_Call { | |
| 1847 | 951 | _c.Call.Return(run) | |
| 1848 | 952 | return _c | |
| 1849 | 953 | } | |
| 1850 | 954 | ||
| 1851 | - | // ListSecretsWithAttachments provides a mock function for the type MockControlPlane | |
| 1852 | - | func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 955 | + | // ListSecrets provides a mock function for the type MockControlPlane | |
| 956 | + | func (_mock *MockControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 1853 | 957 | ret := _mock.Called(ctx, secretDomain) | |
| 1854 | 958 | ||
| 1855 | 959 | if len(ret) == 0 { | |
| 1856 | - | panic("no return value specified for ListSecretsWithAttachments") | |
| 960 | + | panic("no return value specified for ListSecrets") | |
| 1857 | 961 | } | |
| 1858 | 962 | ||
| 1859 | 963 | var r0 *remote.SecretsListResult | |
| @@ -1876,19 +980,19 @@ func (_mock *MockControlPlane) ListSecretsWithAttachments(ctx context.Context, s | |||
| 1876 | 980 | return r0, r1 | |
| 1877 | 981 | } | |
| 1878 | 982 | ||
| 1879 | - | // MockControlPlane_ListSecretsWithAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecretsWithAttachments' | |
| 1880 | - | type MockControlPlane_ListSecretsWithAttachments_Call struct { | |
| 983 | + | // MockControlPlane_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets' | |
| 984 | + | type MockControlPlane_ListSecrets_Call struct { | |
| 1881 | 985 | *mock.Call | |
| 1882 | 986 | } | |
| 1883 | 987 | ||
| 1884 | - | // ListSecretsWithAttachments is a helper method to define mock.On call | |
| 988 | + | // ListSecrets is a helper method to define mock.On call | |
| 1885 | 989 | // - ctx context.Context | |
| 1886 | 990 | // - secretDomain string | |
| 1887 | - | func (_e *MockControlPlane_Expecter) ListSecretsWithAttachments(ctx any, secretDomain any) *MockControlPlane_ListSecretsWithAttachments_Call { | |
| 1888 | - | return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)} | |
| 991 | + | func (_e *MockControlPlane_Expecter) ListSecrets(ctx any, secretDomain any) *MockControlPlane_ListSecrets_Call { | |
| 992 | + | return &MockControlPlane_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, secretDomain)} | |
| 1889 | 993 | } | |
| 1890 | 994 | ||
| 1891 | - | func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecretsWithAttachments_Call { | |
| 995 | + | func (_c *MockControlPlane_ListSecrets_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecrets_Call { | |
| 1892 | 996 | _c.Call.Run(func(args mock.Arguments) { | |
| 1893 | 997 | var arg0 context.Context | |
| 1894 | 998 | if args[0] != nil { | |
| @@ -1906,12 +1010,12 @@ func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Run(run func(ctx con | |||
| 1906 | 1010 | return _c | |
| 1907 | 1011 | } | |
| 1908 | 1012 | ||
| 1909 | - | func (_c *MockControlPlane_ListSecretsWithAttachments_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecretsWithAttachments_Call { | |
| 1013 | + | func (_c *MockControlPlane_ListSecrets_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecrets_Call { | |
| 1910 | 1014 | _c.Call.Return(secretsListResult, err) | |
| 1911 | 1015 | return _c | |
| 1912 | 1016 | } | |
| 1913 | 1017 | ||
| 1914 | - | func (_c *MockControlPlane_ListSecretsWithAttachments_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecretsWithAttachments_Call { | |
| 1018 | + | func (_c *MockControlPlane_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecrets_Call { | |
| 1915 | 1019 | _c.Call.Return(run) | |
| 1916 | 1020 | return _c | |
| 1917 | 1021 | } | |
| @@ -1985,8 +1089,8 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont | |||
| 1985 | 1089 | } | |
| 1986 | 1090 | ||
| 1987 | 1091 | // ListVolumeBackups provides a mock function for the type MockControlPlane | |
| 1988 | - | func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) { | |
| 1989 | - | ret := _mock.Called(ctx, backupDomain) | |
| 1092 | + | func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) { | |
| 1093 | + | ret := _mock.Called(ctx, app) | |
| 1990 | 1094 | ||
| 1991 | 1095 | if len(ret) == 0 { | |
| 1992 | 1096 | panic("no return value specified for ListVolumeBackups") | |
| @@ -1995,17 +1099,17 @@ func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDoma | |||
| 1995 | 1099 | var r0 []dto.VolumeBackupJob | |
| 1996 | 1100 | var r1 error | |
| 1997 | 1101 | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok { | |
| 1998 | - | return returnFunc(ctx, backupDomain) | |
| 1102 | + | return returnFunc(ctx, app) | |
| 1999 | 1103 | } | |
| 2000 | 1104 | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok { | |
| 2001 | - | r0 = returnFunc(ctx, backupDomain) | |
| 1105 | + | r0 = returnFunc(ctx, app) | |
| 2002 | 1106 | } else { | |
| 2003 | 1107 | if ret.Get(0) != nil { | |
| 2004 | 1108 | r0 = ret.Get(0).([]dto.VolumeBackupJob) | |
| 2005 | 1109 | } | |
| 2006 | 1110 | } | |
| 2007 | 1111 | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 2008 | - | r1 = returnFunc(ctx, backupDomain) | |
| 1112 | + | r1 = returnFunc(ctx, app) | |
| 2009 | 1113 | } else { | |
| 2010 | 1114 | r1 = ret.Error(1) | |
| 2011 | 1115 | } | |
| @@ -2019,12 +1123,12 @@ type MockControlPlane_ListVolumeBackups_Call struct { | |||
| 2019 | 1123 | ||
| 2020 | 1124 | // ListVolumeBackups is a helper method to define mock.On call | |
| 2021 | 1125 | // - ctx context.Context | |
| 2022 | - | // - backupDomain string | |
| 2023 | - | func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, backupDomain any) *MockControlPlane_ListVolumeBackups_Call { | |
| 2024 | - | return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)} | |
| 1126 | + | // - app string | |
| 1127 | + | func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, app any) *MockControlPlane_ListVolumeBackups_Call { | |
| 1128 | + | return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, app)} | |
| 2025 | 1129 | } | |
| 2026 | 1130 | ||
| 2027 | - | func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListVolumeBackups_Call { | |
| 1131 | + | func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ListVolumeBackups_Call { | |
| 2028 | 1132 | _c.Call.Run(func(args mock.Arguments) { | |
| 2029 | 1133 | var arg0 context.Context | |
| 2030 | 1134 | if args[0] != nil { | |
| @@ -2047,7 +1151,7 @@ func (_c *MockControlPlane_ListVolumeBackups_Call) Return(volumeBackupJobs []dto | |||
| 2047 | 1151 | return _c | |
| 2048 | 1152 | } | |
| 2049 | 1153 | ||
| 2050 | - | func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call { | |
| 1154 | + | func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, app string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call { | |
| 2051 | 1155 | _c.Call.Return(run) | |
| 2052 | 1156 | return _c | |
| 2053 | 1157 | } | |
| @@ -2085,31 +1189,105 @@ type MockControlPlane_ListVolumes_Call struct { | |||
| 2085 | 1189 | *mock.Call | |
| 2086 | 1190 | } | |
| 2087 | 1191 | ||
| 2088 | - | // ListVolumes is a helper method to define mock.On call | |
| 1192 | + | // ListVolumes is a helper method to define mock.On call | |
| 1193 | + | // - ctx context.Context | |
| 1194 | + | func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call { | |
| 1195 | + | return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)} | |
| 1196 | + | } | |
| 1197 | + | ||
| 1198 | + | func (_c *MockControlPlane_ListVolumes_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListVolumes_Call { | |
| 1199 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1200 | + | var arg0 context.Context | |
| 1201 | + | if args[0] != nil { | |
| 1202 | + | arg0 = args[0].(context.Context) | |
| 1203 | + | } | |
| 1204 | + | run( | |
| 1205 | + | arg0, | |
| 1206 | + | ) | |
| 1207 | + | }) | |
| 1208 | + | return _c | |
| 1209 | + | } | |
| 1210 | + | ||
| 1211 | + | func (_c *MockControlPlane_ListVolumes_Call) Return(volumes []dto.Volume, err error) *MockControlPlane_ListVolumes_Call { | |
| 1212 | + | _c.Call.Return(volumes, err) | |
| 1213 | + | return _c | |
| 1214 | + | } | |
| 1215 | + | ||
| 1216 | + | func (_c *MockControlPlane_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]dto.Volume, error)) *MockControlPlane_ListVolumes_Call { | |
| 1217 | + | _c.Call.Return(run) | |
| 1218 | + | return _c | |
| 1219 | + | } | |
| 1220 | + | ||
| 1221 | + | // OperationByKey provides a mock function for the type MockControlPlane | |
| 1222 | + | func (_mock *MockControlPlane) OperationByKey(ctx context.Context, app string, key string) (*dto.AppDeployResponse, error) { | |
| 1223 | + | ret := _mock.Called(ctx, app, key) | |
| 1224 | + | ||
| 1225 | + | if len(ret) == 0 { | |
| 1226 | + | panic("no return value specified for OperationByKey") | |
| 1227 | + | } | |
| 1228 | + | ||
| 1229 | + | var r0 *dto.AppDeployResponse | |
| 1230 | + | var r1 error | |
| 1231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, error)); ok { | |
| 1232 | + | return returnFunc(ctx, app, key) | |
| 1233 | + | } | |
| 1234 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok { | |
| 1235 | + | r0 = returnFunc(ctx, app, key) | |
| 1236 | + | } else { | |
| 1237 | + | if ret.Get(0) != nil { | |
| 1238 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 1239 | + | } | |
| 1240 | + | } | |
| 1241 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 1242 | + | r1 = returnFunc(ctx, app, key) | |
| 1243 | + | } else { | |
| 1244 | + | r1 = ret.Error(1) | |
| 1245 | + | } | |
| 1246 | + | return r0, r1 | |
| 1247 | + | } | |
| 1248 | + | ||
| 1249 | + | // MockControlPlane_OperationByKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OperationByKey' | |
| 1250 | + | type MockControlPlane_OperationByKey_Call struct { | |
| 1251 | + | *mock.Call | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | // OperationByKey is a helper method to define mock.On call | |
| 2089 | 1255 | // - ctx context.Context | |
| 2090 | - | func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call { | |
| 2091 | - | return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)} | |
| 1256 | + | // - app string | |
| 1257 | + | // - key string | |
| 1258 | + | func (_e *MockControlPlane_Expecter) OperationByKey(ctx any, app any, key any) *MockControlPlane_OperationByKey_Call { | |
| 1259 | + | return &MockControlPlane_OperationByKey_Call{Call: _e.mock.On("OperationByKey", ctx, app, key)} | |
| 2092 | 1260 | } | |
| 2093 | 1261 | ||
| 2094 | - | func (_c *MockControlPlane_ListVolumes_Call) Run(run func(ctx context.Context)) *MockControlPlane_ListVolumes_Call { | |
| 1262 | + | func (_c *MockControlPlane_OperationByKey_Call) Run(run func(ctx context.Context, app string, key string)) *MockControlPlane_OperationByKey_Call { | |
| 2095 | 1263 | _c.Call.Run(func(args mock.Arguments) { | |
| 2096 | 1264 | var arg0 context.Context | |
| 2097 | 1265 | if args[0] != nil { | |
| 2098 | 1266 | arg0 = args[0].(context.Context) | |
| 2099 | 1267 | } | |
| 1268 | + | var arg1 string | |
| 1269 | + | if args[1] != nil { | |
| 1270 | + | arg1 = args[1].(string) | |
| 1271 | + | } | |
| 1272 | + | var arg2 string | |
| 1273 | + | if args[2] != nil { | |
| 1274 | + | arg2 = args[2].(string) | |
| 1275 | + | } | |
| 2100 | 1276 | run( | |
| 2101 | 1277 | arg0, | |
| 1278 | + | arg1, | |
| 1279 | + | arg2, | |
| 2102 | 1280 | ) | |
| 2103 | 1281 | }) | |
| 2104 | 1282 | return _c | |
| 2105 | 1283 | } | |
| 2106 | 1284 | ||
| 2107 | - | func (_c *MockControlPlane_ListVolumes_Call) Return(volumes []dto.Volume, err error) *MockControlPlane_ListVolumes_Call { | |
| 2108 | - | _c.Call.Return(volumes, err) | |
| 1285 | + | func (_c *MockControlPlane_OperationByKey_Call) Return(appDeployResponse *dto.AppDeployResponse, err error) *MockControlPlane_OperationByKey_Call { | |
| 1286 | + | _c.Call.Return(appDeployResponse, err) | |
| 2109 | 1287 | return _c | |
| 2110 | 1288 | } | |
| 2111 | 1289 | ||
| 2112 | - | func (_c *MockControlPlane_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]dto.Volume, error)) *MockControlPlane_ListVolumes_Call { | |
| 1290 | + | func (_c *MockControlPlane_OperationByKey_Call) RunAndReturn(run func(ctx context.Context, app string, key string) (*dto.AppDeployResponse, error)) *MockControlPlane_OperationByKey_Call { | |
| 2113 | 1291 | _c.Call.Return(run) | |
| 2114 | 1292 | return _c | |
| 2115 | 1293 | } | |
| @@ -2233,156 +1411,53 @@ func (_c *MockControlPlane_Reload_Call) RunAndReturn(run func(ctx context.Contex | |||
| 2233 | 1411 | return _c | |
| 2234 | 1412 | } | |
| 2235 | 1413 | ||
| 2236 | - | // RemoveAttachment provides a mock function for the type MockControlPlane | |
| 2237 | - | func (_mock *MockControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error { | |
| 2238 | - | ret := _mock.Called(ctx, domainOrGroup, image) | |
| 1414 | + | // RemoveApp provides a mock function for the type MockControlPlane | |
| 1415 | + | func (_mock *MockControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 1416 | + | ret := _mock.Called(ctx, app) | |
| 2239 | 1417 | ||
| 2240 | 1418 | if len(ret) == 0 { | |
| 2241 | - | panic("no return value specified for RemoveAttachment") | |
| 1419 | + | panic("no return value specified for RemoveApp") | |
| 2242 | 1420 | } | |
| 2243 | 1421 | ||
| 2244 | - | var r0 error | |
| 2245 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 2246 | - | r0 = returnFunc(ctx, domainOrGroup, image) | |
| 2247 | - | } else { | |
| 2248 | - | r0 = ret.Error(0) | |
| 1422 | + | var r0 *dto.AppDeployResponse | |
| 1423 | + | var r1 string | |
| 1424 | + | var r2 error | |
| 1425 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok { | |
| 1426 | + | return returnFunc(ctx, app) | |
| 2249 | 1427 | } | |
| 2250 | - | return r0 | |
| 2251 | - | } | |
| 2252 | - | ||
| 2253 | - | // MockControlPlane_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment' | |
| 2254 | - | type MockControlPlane_RemoveAttachment_Call struct { | |
| 2255 | - | *mock.Call | |
| 2256 | - | } | |
| 2257 | - | ||
| 2258 | - | // RemoveAttachment is a helper method to define mock.On call | |
| 2259 | - | // - ctx context.Context | |
| 2260 | - | // - domainOrGroup string | |
| 2261 | - | // - image string | |
| 2262 | - | func (_e *MockControlPlane_Expecter) RemoveAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_RemoveAttachment_Call { | |
| 2263 | - | return &MockControlPlane_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)} | |
| 2264 | - | } | |
| 2265 | - | ||
| 2266 | - | func (_c *MockControlPlane_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockControlPlane_RemoveAttachment_Call { | |
| 2267 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 2268 | - | var arg0 context.Context | |
| 2269 | - | if args[0] != nil { | |
| 2270 | - | arg0 = args[0].(context.Context) | |
| 2271 | - | } | |
| 2272 | - | var arg1 string | |
| 2273 | - | if args[1] != nil { | |
| 2274 | - | arg1 = args[1].(string) | |
| 2275 | - | } | |
| 2276 | - | var arg2 string | |
| 2277 | - | if args[2] != nil { | |
| 2278 | - | arg2 = args[2].(string) | |
| 1428 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok { | |
| 1429 | + | r0 = returnFunc(ctx, app) | |
| 1430 | + | } else { | |
| 1431 | + | if ret.Get(0) != nil { | |
| 1432 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 2279 | 1433 | } | |
| 2280 | - | run( | |
| 2281 | - | arg0, | |
| 2282 | - | arg1, | |
| 2283 | - | arg2, | |
| 2284 | - | ) | |
| 2285 | - | }) | |
| 2286 | - | return _c | |
| 2287 | - | } | |
| 2288 | - | ||
| 2289 | - | func (_c *MockControlPlane_RemoveAttachment_Call) Return(err error) *MockControlPlane_RemoveAttachment_Call { | |
| 2290 | - | _c.Call.Return(err) | |
| 2291 | - | return _c | |
| 2292 | - | } | |
| 2293 | - | ||
| 2294 | - | func (_c *MockControlPlane_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockControlPlane_RemoveAttachment_Call { | |
| 2295 | - | _c.Call.Return(run) | |
| 2296 | - | return _c | |
| 2297 | - | } | |
| 2298 | - | ||
| 2299 | - | // RemoveAutoRouteAllowedDomain provides a mock function for the type MockControlPlane | |
| 2300 | - | func (_mock *MockControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 2301 | - | ret := _mock.Called(ctx, pattern) | |
| 2302 | - | ||
| 2303 | - | if len(ret) == 0 { | |
| 2304 | - | panic("no return value specified for RemoveAutoRouteAllowedDomain") | |
| 2305 | 1434 | } | |
| 2306 | - | ||
| 2307 | - | var r0 error | |
| 2308 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 2309 | - | r0 = returnFunc(ctx, pattern) | |
| 1435 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok { | |
| 1436 | + | r1 = returnFunc(ctx, app) | |
| 2310 | 1437 | } else { | |
| 2311 | - | r0 = ret.Error(0) | |
| 2312 | - | } | |
| 2313 | - | return r0 | |
| 2314 | - | } | |
| 2315 | - | ||
| 2316 | - | // MockControlPlane_RemoveAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAutoRouteAllowedDomain' | |
| 2317 | - | type MockControlPlane_RemoveAutoRouteAllowedDomain_Call struct { | |
| 2318 | - | *mock.Call | |
| 2319 | - | } | |
| 2320 | - | ||
| 2321 | - | // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 2322 | - | // - ctx context.Context | |
| 2323 | - | // - pattern string | |
| 2324 | - | func (_e *MockControlPlane_Expecter) RemoveAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call { | |
| 2325 | - | return &MockControlPlane_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)} | |
| 2326 | - | } | |
| 2327 | - | ||
| 2328 | - | func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call { | |
| 2329 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 2330 | - | var arg0 context.Context | |
| 2331 | - | if args[0] != nil { | |
| 2332 | - | arg0 = args[0].(context.Context) | |
| 2333 | - | } | |
| 2334 | - | var arg1 string | |
| 2335 | - | if args[1] != nil { | |
| 2336 | - | arg1 = args[1].(string) | |
| 2337 | - | } | |
| 2338 | - | run( | |
| 2339 | - | arg0, | |
| 2340 | - | arg1, | |
| 2341 | - | ) | |
| 2342 | - | }) | |
| 2343 | - | return _c | |
| 2344 | - | } | |
| 2345 | - | ||
| 2346 | - | func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) Return(err error) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call { | |
| 2347 | - | _c.Call.Return(err) | |
| 2348 | - | return _c | |
| 2349 | - | } | |
| 2350 | - | ||
| 2351 | - | func (_c *MockControlPlane_RemoveAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call { | |
| 2352 | - | _c.Call.Return(run) | |
| 2353 | - | return _c | |
| 2354 | - | } | |
| 2355 | - | ||
| 2356 | - | // RemoveRoute provides a mock function for the type MockControlPlane | |
| 2357 | - | func (_mock *MockControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 2358 | - | ret := _mock.Called(ctx, routeDomain) | |
| 2359 | - | ||
| 2360 | - | if len(ret) == 0 { | |
| 2361 | - | panic("no return value specified for RemoveRoute") | |
| 1438 | + | r1 = ret.Get(1).(string) | |
| 2362 | 1439 | } | |
| 2363 | - | ||
| 2364 | - | var r0 error | |
| 2365 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 2366 | - | r0 = returnFunc(ctx, routeDomain) | |
| 1440 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 1441 | + | r2 = returnFunc(ctx, app) | |
| 2367 | 1442 | } else { | |
| 2368 | - | r0 = ret.Error(0) | |
| 1443 | + | r2 = ret.Error(2) | |
| 2369 | 1444 | } | |
| 2370 | - | return r0 | |
| 1445 | + | return r0, r1, r2 | |
| 2371 | 1446 | } | |
| 2372 | 1447 | ||
| 2373 | - | // MockControlPlane_RemoveRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRoute' | |
| 2374 | - | type MockControlPlane_RemoveRoute_Call struct { | |
| 1448 | + | // MockControlPlane_RemoveApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveApp' | |
| 1449 | + | type MockControlPlane_RemoveApp_Call struct { | |
| 2375 | 1450 | *mock.Call | |
| 2376 | 1451 | } | |
| 2377 | 1452 | ||
| 2378 | - | // RemoveRoute is a helper method to define mock.On call | |
| 1453 | + | // RemoveApp is a helper method to define mock.On call | |
| 2379 | 1454 | // - ctx context.Context | |
| 2380 | - | // - routeDomain string | |
| 2381 | - | func (_e *MockControlPlane_Expecter) RemoveRoute(ctx any, routeDomain any) *MockControlPlane_RemoveRoute_Call { | |
| 2382 | - | return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)} | |
| 1455 | + | // - app string | |
| 1456 | + | func (_e *MockControlPlane_Expecter) RemoveApp(ctx any, app any) *MockControlPlane_RemoveApp_Call { | |
| 1457 | + | return &MockControlPlane_RemoveApp_Call{Call: _e.mock.On("RemoveApp", ctx, app)} | |
| 2383 | 1458 | } | |
| 2384 | 1459 | ||
| 2385 | - | func (_c *MockControlPlane_RemoveRoute_Call) Run(run func(ctx context.Context, routeDomain string)) *MockControlPlane_RemoveRoute_Call { | |
| 1460 | + | func (_c *MockControlPlane_RemoveApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_RemoveApp_Call { | |
| 2386 | 1461 | _c.Call.Run(func(args mock.Arguments) { | |
| 2387 | 1462 | var arg0 context.Context | |
| 2388 | 1463 | if args[0] != nil { | |
| @@ -2400,58 +1475,64 @@ func (_c *MockControlPlane_RemoveRoute_Call) Run(run func(ctx context.Context, r | |||
| 2400 | 1475 | return _c | |
| 2401 | 1476 | } | |
| 2402 | 1477 | ||
| 2403 | - | func (_c *MockControlPlane_RemoveRoute_Call) Return(err error) *MockControlPlane_RemoveRoute_Call { | |
| 2404 | - | _c.Call.Return(err) | |
| 1478 | + | func (_c *MockControlPlane_RemoveApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RemoveApp_Call { | |
| 1479 | + | _c.Call.Return(appDeployResponse, s, err) | |
| 2405 | 1480 | return _c | |
| 2406 | 1481 | } | |
| 2407 | 1482 | ||
| 2408 | - | func (_c *MockControlPlane_RemoveRoute_Call) RunAndReturn(run func(ctx context.Context, routeDomain string) error) *MockControlPlane_RemoveRoute_Call { | |
| 1483 | + | func (_c *MockControlPlane_RemoveApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RemoveApp_Call { | |
| 2409 | 1484 | _c.Call.Return(run) | |
| 2410 | 1485 | return _c | |
| 2411 | 1486 | } | |
| 2412 | 1487 | ||
| 2413 | - | // Restart provides a mock function for the type MockControlPlane | |
| 2414 | - | func (_mock *MockControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |
| 2415 | - | ret := _mock.Called(ctx, restartDomain, withAttachments) | |
| 1488 | + | // RestartApp provides a mock function for the type MockControlPlane | |
| 1489 | + | func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error) { | |
| 1490 | + | ret := _mock.Called(ctx, app, service) | |
| 2416 | 1491 | ||
| 2417 | 1492 | if len(ret) == 0 { | |
| 2418 | - | panic("no return value specified for Restart") | |
| 1493 | + | panic("no return value specified for RestartApp") | |
| 2419 | 1494 | } | |
| 2420 | 1495 | ||
| 2421 | - | var r0 *remote.RestartResult | |
| 2422 | - | var r1 error | |
| 2423 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*remote.RestartResult, error)); ok { | |
| 2424 | - | return returnFunc(ctx, restartDomain, withAttachments) | |
| 1496 | + | var r0 *dto.AppDeployResponse | |
| 1497 | + | var r1 string | |
| 1498 | + | var r2 error | |
| 1499 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, string, error)); ok { | |
| 1500 | + | return returnFunc(ctx, app, service) | |
| 2425 | 1501 | } | |
| 2426 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *remote.RestartResult); ok { | |
| 2427 | - | r0 = returnFunc(ctx, restartDomain, withAttachments) | |
| 1502 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok { | |
| 1503 | + | r0 = returnFunc(ctx, app, service) | |
| 2428 | 1504 | } else { | |
| 2429 | 1505 | if ret.Get(0) != nil { | |
| 2430 | - | r0 = ret.Get(0).(*remote.RestartResult) | |
| 1506 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 2431 | 1507 | } | |
| 2432 | 1508 | } | |
| 2433 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { | |
| 2434 | - | r1 = returnFunc(ctx, restartDomain, withAttachments) | |
| 1509 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok { | |
| 1510 | + | r1 = returnFunc(ctx, app, service) | |
| 2435 | 1511 | } else { | |
| 2436 | - | r1 = ret.Error(1) | |
| 1512 | + | r1 = ret.Get(1).(string) | |
| 2437 | 1513 | } | |
| 2438 | - | return r0, r1 | |
| 1514 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok { | |
| 1515 | + | r2 = returnFunc(ctx, app, service) | |
| 1516 | + | } else { | |
| 1517 | + | r2 = ret.Error(2) | |
| 1518 | + | } | |
| 1519 | + | return r0, r1, r2 | |
| 2439 | 1520 | } | |
| 2440 | 1521 | ||
| 2441 | - | // MockControlPlane_Restart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restart' | |
| 2442 | - | type MockControlPlane_Restart_Call struct { | |
| 1522 | + | // MockControlPlane_RestartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartApp' | |
| 1523 | + | type MockControlPlane_RestartApp_Call struct { | |
| 2443 | 1524 | *mock.Call | |
| 2444 | 1525 | } | |
| 2445 | 1526 | ||
| 2446 | - | // Restart is a helper method to define mock.On call | |
| 1527 | + | // RestartApp is a helper method to define mock.On call | |
| 2447 | 1528 | // - ctx context.Context | |
| 2448 | - | // - restartDomain string | |
| 2449 | - | // - withAttachments bool | |
| 2450 | - | func (_e *MockControlPlane_Expecter) Restart(ctx any, restartDomain any, withAttachments any) *MockControlPlane_Restart_Call { | |
| 2451 | - | return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)} | |
| 1529 | + | // - app string | |
| 1530 | + | // - service string | |
| 1531 | + | func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any) *MockControlPlane_RestartApp_Call { | |
| 1532 | + | return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service)} | |
| 2452 | 1533 | } | |
| 2453 | 1534 | ||
| 2454 | - | func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, restartDomain string, withAttachments bool)) *MockControlPlane_Restart_Call { | |
| 1535 | + | func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string)) *MockControlPlane_RestartApp_Call { | |
| 2455 | 1536 | _c.Call.Run(func(args mock.Arguments) { | |
| 2456 | 1537 | var arg0 context.Context | |
| 2457 | 1538 | if args[0] != nil { | |
| @@ -2461,9 +1542,9 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta | |||
| 2461 | 1542 | if args[1] != nil { | |
| 2462 | 1543 | arg1 = args[1].(string) | |
| 2463 | 1544 | } | |
| 2464 | - | var arg2 bool | |
| 1545 | + | var arg2 string | |
| 2465 | 1546 | if args[2] != nil { | |
| 2466 | - | arg2 = args[2].(bool) | |
| 1547 | + | arg2 = args[2].(string) | |
| 2467 | 1548 | } | |
| 2468 | 1549 | run( | |
| 2469 | 1550 | arg0, | |
| @@ -2474,19 +1555,19 @@ func (_c *MockControlPlane_Restart_Call) Run(run func(ctx context.Context, resta | |||
| 2474 | 1555 | return _c | |
| 2475 | 1556 | } | |
| 2476 | 1557 | ||
| 2477 | - | func (_c *MockControlPlane_Restart_Call) Return(restartResult *remote.RestartResult, err error) *MockControlPlane_Restart_Call { | |
| 2478 | - | _c.Call.Return(restartResult, err) | |
| 1558 | + | func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_RestartApp_Call { | |
| 1559 | + | _c.Call.Return(appDeployResponse, s, err) | |
| 2479 | 1560 | return _c | |
| 2480 | 1561 | } | |
| 2481 | 1562 | ||
| 2482 | - | func (_c *MockControlPlane_Restart_Call) RunAndReturn(run func(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)) *MockControlPlane_Restart_Call { | |
| 1563 | + | func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call { | |
| 2483 | 1564 | _c.Call.Return(run) | |
| 2484 | 1565 | return _c | |
| 2485 | 1566 | } | |
| 2486 | 1567 | ||
| 2487 | 1568 | // RunBackup provides a mock function for the type MockControlPlane | |
| 2488 | - | func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain string, dbName string) (*dto.BackupRunResponse, error) { | |
| 2489 | - | ret := _mock.Called(ctx, backupDomain, dbName) | |
| 1569 | + | func (_mock *MockControlPlane) RunBackup(ctx context.Context, app string, service string, database string) (*dto.BackupRunResponse, error) { | |
| 1570 | + | ret := _mock.Called(ctx, app, service, database) | |
| 2490 | 1571 | ||
| 2491 | 1572 | if len(ret) == 0 { | |
| 2492 | 1573 | panic("no return value specified for RunBackup") | |
| @@ -2494,18 +1575,18 @@ func (_mock *MockControlPlane) RunBackup(ctx context.Context, backupDomain strin | |||
| 2494 | 1575 | ||
| 2495 | 1576 | var r0 *dto.BackupRunResponse | |
| 2496 | 1577 | var r1 error | |
| 2497 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.BackupRunResponse, error)); ok { | |
| 2498 | - | return returnFunc(ctx, backupDomain, dbName) | |
| 1578 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.BackupRunResponse, error)); ok { | |
| 1579 | + | return returnFunc(ctx, app, service, database) | |
| 2499 | 1580 | } | |
| 2500 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.BackupRunResponse); ok { | |
| 2501 | - | r0 = returnFunc(ctx, backupDomain, dbName) | |
| 1581 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.BackupRunResponse); ok { | |
| 1582 | + | r0 = returnFunc(ctx, app, service, database) | |
| 2502 | 1583 | } else { | |
| 2503 | 1584 | if ret.Get(0) != nil { | |
| 2504 | 1585 | r0 = ret.Get(0).(*dto.BackupRunResponse) | |
| 2505 | 1586 | } | |
| 2506 | 1587 | } | |
| 2507 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 2508 | - | r1 = returnFunc(ctx, backupDomain, dbName) | |
| 1588 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { | |
| 1589 | + | r1 = returnFunc(ctx, app, service, database) | |
| 2509 | 1590 | } else { | |
| 2510 | 1591 | r1 = ret.Error(1) | |
| 2511 | 1592 | } | |
| @@ -2519,13 +1600,14 @@ type MockControlPlane_RunBackup_Call struct { | |||
| 2519 | 1600 | ||
| 2520 | 1601 | // RunBackup is a helper method to define mock.On call | |
| 2521 | 1602 | // - ctx context.Context | |
| 2522 | - | // - backupDomain string | |
| 2523 | - | // - dbName string | |
| 2524 | - | func (_e *MockControlPlane_Expecter) RunBackup(ctx any, backupDomain any, dbName any) *MockControlPlane_RunBackup_Call { | |
| 2525 | - | return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)} | |
| 1603 | + | // - app string | |
| 1604 | + | // - service string | |
| 1605 | + | // - database string | |
| 1606 | + | func (_e *MockControlPlane_Expecter) RunBackup(ctx any, app any, service any, database any) *MockControlPlane_RunBackup_Call { | |
| 1607 | + | return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, app, service, database)} | |
| 2526 | 1608 | } | |
| 2527 | 1609 | ||
| 2528 | - | func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, backupDomain string, dbName string)) *MockControlPlane_RunBackup_Call { | |
| 1610 | + | func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, app string, service string, database string)) *MockControlPlane_RunBackup_Call { | |
| 2529 | 1611 | _c.Call.Run(func(args mock.Arguments) { | |
| 2530 | 1612 | var arg0 context.Context | |
| 2531 | 1613 | if args[0] != nil { | |
| @@ -2539,10 +1621,15 @@ func (_c *MockControlPlane_RunBackup_Call) Run(run func(ctx context.Context, bac | |||
| 2539 | 1621 | if args[2] != nil { | |
| 2540 | 1622 | arg2 = args[2].(string) | |
| 2541 | 1623 | } | |
| 1624 | + | var arg3 string | |
| 1625 | + | if args[3] != nil { | |
| 1626 | + | arg3 = args[3].(string) | |
| 1627 | + | } | |
| 2542 | 1628 | run( | |
| 2543 | 1629 | arg0, | |
| 2544 | 1630 | arg1, | |
| 2545 | 1631 | arg2, | |
| 1632 | + | arg3, | |
| 2546 | 1633 | ) | |
| 2547 | 1634 | }) | |
| 2548 | 1635 | return _c | |
| @@ -2553,14 +1640,14 @@ func (_c *MockControlPlane_RunBackup_Call) Return(backupRunResponse *dto.BackupR | |||
| 2553 | 1640 | return _c | |
| 2554 | 1641 | } | |
| 2555 | 1642 | ||
| 2556 | - | func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, dbName string) (*dto.BackupRunResponse, error)) *MockControlPlane_RunBackup_Call { | |
| 1643 | + | func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Context, app string, service string, database string) (*dto.BackupRunResponse, error)) *MockControlPlane_RunBackup_Call { | |
| 2557 | 1644 | _c.Call.Return(run) | |
| 2558 | 1645 | return _c | |
| 2559 | 1646 | } | |
| 2560 | 1647 | ||
| 2561 | 1648 | // RunVolumeBackups provides a mock function for the type MockControlPlane | |
| 2562 | - | func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error) { | |
| 2563 | - | ret := _mock.Called(ctx, backupDomain, volumeName) | |
| 1649 | + | func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, app string, service string, volume string) (*dto.VolumeBackupRunResponse, error) { | |
| 1650 | + | ret := _mock.Called(ctx, app, service, volume) | |
| 2564 | 1651 | ||
| 2565 | 1652 | if len(ret) == 0 { | |
| 2566 | 1653 | panic("no return value specified for RunVolumeBackups") | |
| @@ -2568,18 +1655,18 @@ func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomai | |||
| 2568 | 1655 | ||
| 2569 | 1656 | var r0 *dto.VolumeBackupRunResponse | |
| 2570 | 1657 | var r1 error | |
| 2571 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.VolumeBackupRunResponse, error)); ok { | |
| 2572 | - | return returnFunc(ctx, backupDomain, volumeName) | |
| 1658 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*dto.VolumeBackupRunResponse, error)); ok { | |
| 1659 | + | return returnFunc(ctx, app, service, volume) | |
| 2573 | 1660 | } | |
| 2574 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.VolumeBackupRunResponse); ok { | |
| 2575 | - | r0 = returnFunc(ctx, backupDomain, volumeName) | |
| 1661 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *dto.VolumeBackupRunResponse); ok { | |
| 1662 | + | r0 = returnFunc(ctx, app, service, volume) | |
| 2576 | 1663 | } else { | |
| 2577 | 1664 | if ret.Get(0) != nil { | |
| 2578 | 1665 | r0 = ret.Get(0).(*dto.VolumeBackupRunResponse) | |
| 2579 | 1666 | } | |
| 2580 | 1667 | } | |
| 2581 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 2582 | - | r1 = returnFunc(ctx, backupDomain, volumeName) | |
| 1668 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { | |
| 1669 | + | r1 = returnFunc(ctx, app, service, volume) | |
| 2583 | 1670 | } else { | |
| 2584 | 1671 | r1 = ret.Error(1) | |
| 2585 | 1672 | } | |
| @@ -2593,13 +1680,14 @@ type MockControlPlane_RunVolumeBackups_Call struct { | |||
| 2593 | 1680 | ||
| 2594 | 1681 | // RunVolumeBackups is a helper method to define mock.On call | |
| 2595 | 1682 | // - ctx context.Context | |
| 2596 | - | // - backupDomain string | |
| 2597 | - | // - volumeName string | |
| 2598 | - | func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, backupDomain any, volumeName any) *MockControlPlane_RunVolumeBackups_Call { | |
| 2599 | - | return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)} | |
| 1683 | + | // - app string | |
| 1684 | + | // - service string | |
| 1685 | + | // - volume string | |
| 1686 | + | func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, app any, service any, volume any) *MockControlPlane_RunVolumeBackups_Call { | |
| 1687 | + | return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, app, service, volume)} | |
| 2600 | 1688 | } | |
| 2601 | 1689 | ||
| 2602 | - | func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string, volumeName string)) *MockControlPlane_RunVolumeBackups_Call { | |
| 1690 | + | func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, app string, service string, volume string)) *MockControlPlane_RunVolumeBackups_Call { | |
| 2603 | 1691 | _c.Call.Run(func(args mock.Arguments) { | |
| 2604 | 1692 | var arg0 context.Context | |
| 2605 | 1693 | if args[0] != nil { | |
| @@ -2613,10 +1701,15 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Conte | |||
| 2613 | 1701 | if args[2] != nil { | |
| 2614 | 1702 | arg2 = args[2].(string) | |
| 2615 | 1703 | } | |
| 1704 | + | var arg3 string | |
| 1705 | + | if args[3] != nil { | |
| 1706 | + | arg3 = args[3].(string) | |
| 1707 | + | } | |
| 2616 | 1708 | run( | |
| 2617 | 1709 | arg0, | |
| 2618 | 1710 | arg1, | |
| 2619 | 1711 | arg2, | |
| 1712 | + | arg3, | |
| 2620 | 1713 | ) | |
| 2621 | 1714 | }) | |
| 2622 | 1715 | return _c | |
| @@ -2627,43 +1720,42 @@ func (_c *MockControlPlane_RunVolumeBackups_Call) Return(volumeBackupRunResponse | |||
| 2627 | 1720 | return _c | |
| 2628 | 1721 | } | |
| 2629 | 1722 | ||
| 2630 | - | func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call { | |
| 1723 | + | func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, app string, service string, volume string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call { | |
| 2631 | 1724 | _c.Call.Return(run) | |
| 2632 | 1725 | return _c | |
| 2633 | 1726 | } | |
| 2634 | 1727 | ||
| 2635 | - | // SetAttachmentSecrets provides a mock function for the type MockControlPlane | |
| 2636 | - | func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error { | |
| 2637 | - | ret := _mock.Called(ctx, domain1, service, secrets) | |
| 1728 | + | // SetAppSecrets provides a mock function for the type MockControlPlane | |
| 1729 | + | func (_mock *MockControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error { | |
| 1730 | + | ret := _mock.Called(ctx, app, req) | |
| 2638 | 1731 | ||
| 2639 | 1732 | if len(ret) == 0 { | |
| 2640 | - | panic("no return value specified for SetAttachmentSecrets") | |
| 1733 | + | panic("no return value specified for SetAppSecrets") | |
| 2641 | 1734 | } | |
| 2642 | 1735 | ||
| 2643 | 1736 | var r0 error | |
| 2644 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok { | |
| 2645 | - | r0 = returnFunc(ctx, domain1, service, secrets) | |
| 1737 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, dto.AppSecretSetRequest) error); ok { | |
| 1738 | + | r0 = returnFunc(ctx, app, req) | |
| 2646 | 1739 | } else { | |
| 2647 | 1740 | r0 = ret.Error(0) | |
| 2648 | 1741 | } | |
| 2649 | 1742 | return r0 | |
| 2650 | 1743 | } | |
| 2651 | 1744 | ||
| 2652 | - | // MockControlPlane_SetAttachmentSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachmentSecrets' | |
| 2653 | - | type MockControlPlane_SetAttachmentSecrets_Call struct { | |
| 1745 | + | // MockControlPlane_SetAppSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAppSecrets' | |
| 1746 | + | type MockControlPlane_SetAppSecrets_Call struct { | |
| 2654 | 1747 | *mock.Call | |
| 2655 | 1748 | } | |
| 2656 | 1749 | ||
| 2657 | - | // SetAttachmentSecrets is a helper method to define mock.On call | |
| 1750 | + | // SetAppSecrets is a helper method to define mock.On call | |
| 2658 | 1751 | // - ctx context.Context | |
| 2659 | - | // - domain1 string | |
| 2660 | - | // - service string | |
| 2661 | - | // - secrets map[string]string | |
| 2662 | - | func (_e *MockControlPlane_Expecter) SetAttachmentSecrets(ctx any, domain1 any, service any, secrets any) *MockControlPlane_SetAttachmentSecrets_Call { | |
| 2663 | - | return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)} | |
| 1752 | + | // - app string | |
| 1753 | + | // - req dto.AppSecretSetRequest | |
| 1754 | + | func (_e *MockControlPlane_Expecter) SetAppSecrets(ctx any, app any, req any) *MockControlPlane_SetAppSecrets_Call { | |
| 1755 | + | return &MockControlPlane_SetAppSecrets_Call{Call: _e.mock.On("SetAppSecrets", ctx, app, req)} | |
| 2664 | 1756 | } | |
| 2665 | 1757 | ||
| 2666 | - | func (_c *MockControlPlane_SetAttachmentSecrets_Call) Run(run func(ctx context.Context, domain1 string, service string, secrets map[string]string)) *MockControlPlane_SetAttachmentSecrets_Call { | |
| 1758 | + | func (_c *MockControlPlane_SetAppSecrets_Call) Run(run func(ctx context.Context, app string, req dto.AppSecretSetRequest)) *MockControlPlane_SetAppSecrets_Call { | |
| 2667 | 1759 | _c.Call.Run(func(args mock.Arguments) { | |
| 2668 | 1760 | var arg0 context.Context | |
| 2669 | 1761 | if args[0] != nil { | |
| @@ -2673,30 +1765,25 @@ func (_c *MockControlPlane_SetAttachmentSecrets_Call) Run(run func(ctx context.C | |||
| 2673 | 1765 | if args[1] != nil { | |
| 2674 | 1766 | arg1 = args[1].(string) | |
| 2675 | 1767 | } | |
| 2676 | - | var arg2 string | |
| 1768 | + | var arg2 dto.AppSecretSetRequest | |
| 2677 | 1769 | if args[2] != nil { | |
| 2678 | - | arg2 = args[2].(string) | |
| 2679 | - | } | |
| 2680 | - | var arg3 map[string]string | |
| 2681 | - | if args[3] != nil { | |
| 2682 | - | arg3 = args[3].(map[string]string) | |
| 1770 | + | arg2 = args[2].(dto.AppSecretSetRequest) | |
| 2683 | 1771 | } | |
| 2684 | 1772 | run( | |
| 2685 | 1773 | arg0, | |
| 2686 | 1774 | arg1, | |
| 2687 | 1775 | arg2, | |
| 2688 | - | arg3, | |
| 2689 | 1776 | ) | |
| 2690 | 1777 | }) | |
| 2691 | 1778 | return _c | |
| 2692 | 1779 | } | |
| 2693 | 1780 | ||
| 2694 | - | func (_c *MockControlPlane_SetAttachmentSecrets_Call) Return(err error) *MockControlPlane_SetAttachmentSecrets_Call { | |
| 1781 | + | func (_c *MockControlPlane_SetAppSecrets_Call) Return(err error) *MockControlPlane_SetAppSecrets_Call { | |
| 2695 | 1782 | _c.Call.Return(err) | |
| 2696 | 1783 | return _c | |
| 2697 | 1784 | } | |
| 2698 | 1785 | ||
| 2699 | - | func (_c *MockControlPlane_SetAttachmentSecrets_Call) RunAndReturn(run func(ctx context.Context, domain1 string, service string, secrets map[string]string) error) *MockControlPlane_SetAttachmentSecrets_Call { | |
| 1786 | + | func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context.Context, app string, req dto.AppSecretSetRequest) error) *MockControlPlane_SetAppSecrets_Call { | |
| 2700 | 1787 | _c.Call.Return(run) | |
| 2701 | 1788 | return _c | |
| 2702 | 1789 | } | |
| @@ -2764,48 +1851,47 @@ func (_c *MockControlPlane_SetSecrets_Call) RunAndReturn(run func(ctx context.Co | |||
| 2764 | 1851 | return _c | |
| 2765 | 1852 | } | |
| 2766 | 1853 | ||
| 2767 | - | // StreamContainerLogs provides a mock function for the type MockControlPlane | |
| 2768 | - | func (_mock *MockControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 2769 | - | ret := _mock.Called(ctx, logDomain, lines) | |
| 1854 | + | // ShowApp provides a mock function for the type MockControlPlane | |
| 1855 | + | func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | |
| 1856 | + | ret := _mock.Called(ctx, app) | |
| 2770 | 1857 | ||
| 2771 | 1858 | if len(ret) == 0 { | |
| 2772 | - | panic("no return value specified for StreamContainerLogs") | |
| 1859 | + | panic("no return value specified for ShowApp") | |
| 2773 | 1860 | } | |
| 2774 | 1861 | ||
| 2775 | - | var r0 <-chan string | |
| 1862 | + | var r0 *dto.AppShowResponse | |
| 2776 | 1863 | var r1 error | |
| 2777 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (<-chan string, error)); ok { | |
| 2778 | - | return returnFunc(ctx, logDomain, lines) | |
| 1864 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppShowResponse, error)); ok { | |
| 1865 | + | return returnFunc(ctx, app) | |
| 2779 | 1866 | } | |
| 2780 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) <-chan string); ok { | |
| 2781 | - | r0 = returnFunc(ctx, logDomain, lines) | |
| 1867 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppShowResponse); ok { | |
| 1868 | + | r0 = returnFunc(ctx, app) | |
| 2782 | 1869 | } else { | |
| 2783 | 1870 | if ret.Get(0) != nil { | |
| 2784 | - | r0 = ret.Get(0).(<-chan string) | |
| 1871 | + | r0 = ret.Get(0).(*dto.AppShowResponse) | |
| 2785 | 1872 | } | |
| 2786 | 1873 | } | |
| 2787 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok { | |
| 2788 | - | r1 = returnFunc(ctx, logDomain, lines) | |
| 1874 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 1875 | + | r1 = returnFunc(ctx, app) | |
| 2789 | 1876 | } else { | |
| 2790 | 1877 | r1 = ret.Error(1) | |
| 2791 | 1878 | } | |
| 2792 | 1879 | return r0, r1 | |
| 2793 | 1880 | } | |
| 2794 | 1881 | ||
| 2795 | - | // MockControlPlane_StreamContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamContainerLogs' | |
| 2796 | - | type MockControlPlane_StreamContainerLogs_Call struct { | |
| 1882 | + | // MockControlPlane_ShowApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShowApp' | |
| 1883 | + | type MockControlPlane_ShowApp_Call struct { | |
| 2797 | 1884 | *mock.Call | |
| 2798 | 1885 | } | |
| 2799 | 1886 | ||
| 2800 | - | // StreamContainerLogs is a helper method to define mock.On call | |
| 1887 | + | // ShowApp is a helper method to define mock.On call | |
| 2801 | 1888 | // - ctx context.Context | |
| 2802 | - | // - logDomain string | |
| 2803 | - | // - lines int | |
| 2804 | - | func (_e *MockControlPlane_Expecter) StreamContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_StreamContainerLogs_Call { | |
| 2805 | - | return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)} | |
| 1889 | + | // - app string | |
| 1890 | + | func (_e *MockControlPlane_Expecter) ShowApp(ctx any, app any) *MockControlPlane_ShowApp_Call { | |
| 1891 | + | return &MockControlPlane_ShowApp_Call{Call: _e.mock.On("ShowApp", ctx, app)} | |
| 2806 | 1892 | } | |
| 2807 | 1893 | ||
| 2808 | - | func (_c *MockControlPlane_StreamContainerLogs_Call) Run(run func(ctx context.Context, logDomain string, lines int)) *MockControlPlane_StreamContainerLogs_Call { | |
| 1894 | + | func (_c *MockControlPlane_ShowApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_ShowApp_Call { | |
| 2809 | 1895 | _c.Call.Run(func(args mock.Arguments) { | |
| 2810 | 1896 | var arg0 context.Context | |
| 2811 | 1897 | if args[0] != nil { | |
| @@ -2815,78 +1901,153 @@ func (_c *MockControlPlane_StreamContainerLogs_Call) Run(run func(ctx context.Co | |||
| 2815 | 1901 | if args[1] != nil { | |
| 2816 | 1902 | arg1 = args[1].(string) | |
| 2817 | 1903 | } | |
| 2818 | - | var arg2 int | |
| 2819 | - | if args[2] != nil { | |
| 2820 | - | arg2 = args[2].(int) | |
| 1904 | + | run( | |
| 1905 | + | arg0, | |
| 1906 | + | arg1, | |
| 1907 | + | ) | |
| 1908 | + | }) | |
| 1909 | + | return _c | |
| 1910 | + | } | |
| 1911 | + | ||
| 1912 | + | func (_c *MockControlPlane_ShowApp_Call) Return(appShowResponse *dto.AppShowResponse, err error) *MockControlPlane_ShowApp_Call { | |
| 1913 | + | _c.Call.Return(appShowResponse, err) | |
| 1914 | + | return _c | |
| 1915 | + | } | |
| 1916 | + | ||
| 1917 | + | func (_c *MockControlPlane_ShowApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppShowResponse, error)) *MockControlPlane_ShowApp_Call { | |
| 1918 | + | _c.Call.Return(run) | |
| 1919 | + | return _c | |
| 1920 | + | } | |
| 1921 | + | ||
| 1922 | + | // StartApp provides a mock function for the type MockControlPlane | |
| 1923 | + | func (_mock *MockControlPlane) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 1924 | + | ret := _mock.Called(ctx, app) | |
| 1925 | + | ||
| 1926 | + | if len(ret) == 0 { | |
| 1927 | + | panic("no return value specified for StartApp") | |
| 1928 | + | } | |
| 1929 | + | ||
| 1930 | + | var r0 *dto.AppDeployResponse | |
| 1931 | + | var r1 string | |
| 1932 | + | var r2 error | |
| 1933 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok { | |
| 1934 | + | return returnFunc(ctx, app) | |
| 1935 | + | } | |
| 1936 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok { | |
| 1937 | + | r0 = returnFunc(ctx, app) | |
| 1938 | + | } else { | |
| 1939 | + | if ret.Get(0) != nil { | |
| 1940 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 1941 | + | } | |
| 1942 | + | } | |
| 1943 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok { | |
| 1944 | + | r1 = returnFunc(ctx, app) | |
| 1945 | + | } else { | |
| 1946 | + | r1 = ret.Get(1).(string) | |
| 1947 | + | } | |
| 1948 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 1949 | + | r2 = returnFunc(ctx, app) | |
| 1950 | + | } else { | |
| 1951 | + | r2 = ret.Error(2) | |
| 1952 | + | } | |
| 1953 | + | return r0, r1, r2 | |
| 1954 | + | } | |
| 1955 | + | ||
| 1956 | + | // MockControlPlane_StartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartApp' | |
| 1957 | + | type MockControlPlane_StartApp_Call struct { | |
| 1958 | + | *mock.Call | |
| 1959 | + | } | |
| 1960 | + | ||
| 1961 | + | // StartApp is a helper method to define mock.On call | |
| 1962 | + | // - ctx context.Context | |
| 1963 | + | // - app string | |
| 1964 | + | func (_e *MockControlPlane_Expecter) StartApp(ctx any, app any) *MockControlPlane_StartApp_Call { | |
| 1965 | + | return &MockControlPlane_StartApp_Call{Call: _e.mock.On("StartApp", ctx, app)} | |
| 1966 | + | } | |
| 1967 | + | ||
| 1968 | + | func (_c *MockControlPlane_StartApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_StartApp_Call { | |
| 1969 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1970 | + | var arg0 context.Context | |
| 1971 | + | if args[0] != nil { | |
| 1972 | + | arg0 = args[0].(context.Context) | |
| 1973 | + | } | |
| 1974 | + | var arg1 string | |
| 1975 | + | if args[1] != nil { | |
| 1976 | + | arg1 = args[1].(string) | |
| 2821 | 1977 | } | |
| 2822 | 1978 | run( | |
| 2823 | 1979 | arg0, | |
| 2824 | 1980 | arg1, | |
| 2825 | - | arg2, | |
| 2826 | 1981 | ) | |
| 2827 | 1982 | }) | |
| 2828 | 1983 | return _c | |
| 2829 | 1984 | } | |
| 2830 | 1985 | ||
| 2831 | - | func (_c *MockControlPlane_StreamContainerLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamContainerLogs_Call { | |
| 2832 | - | _c.Call.Return(stringCh, err) | |
| 1986 | + | func (_c *MockControlPlane_StartApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StartApp_Call { | |
| 1987 | + | _c.Call.Return(appDeployResponse, s, err) | |
| 2833 | 1988 | return _c | |
| 2834 | 1989 | } | |
| 2835 | 1990 | ||
| 2836 | - | func (_c *MockControlPlane_StreamContainerLogs_Call) RunAndReturn(run func(ctx context.Context, logDomain string, lines int) (<-chan string, error)) *MockControlPlane_StreamContainerLogs_Call { | |
| 1991 | + | func (_c *MockControlPlane_StartApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_StartApp_Call { | |
| 2837 | 1992 | _c.Call.Return(run) | |
| 2838 | 1993 | return _c | |
| 2839 | 1994 | } | |
| 2840 | 1995 | ||
| 2841 | - | // StreamProcessLogs provides a mock function for the type MockControlPlane | |
| 2842 | - | func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 2843 | - | ret := _mock.Called(ctx, lines) | |
| 1996 | + | // StopApp provides a mock function for the type MockControlPlane | |
| 1997 | + | func (_mock *MockControlPlane) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 1998 | + | ret := _mock.Called(ctx, app) | |
| 2844 | 1999 | ||
| 2845 | 2000 | if len(ret) == 0 { | |
| 2846 | - | panic("no return value specified for StreamProcessLogs") | |
| 2001 | + | panic("no return value specified for StopApp") | |
| 2847 | 2002 | } | |
| 2848 | 2003 | ||
| 2849 | - | var r0 <-chan string | |
| 2850 | - | var r1 error | |
| 2851 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok { | |
| 2852 | - | return returnFunc(ctx, lines) | |
| 2004 | + | var r0 *dto.AppDeployResponse | |
| 2005 | + | var r1 string | |
| 2006 | + | var r2 error | |
| 2007 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*dto.AppDeployResponse, string, error)); ok { | |
| 2008 | + | return returnFunc(ctx, app) | |
| 2853 | 2009 | } | |
| 2854 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok { | |
| 2855 | - | r0 = returnFunc(ctx, lines) | |
| 2010 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *dto.AppDeployResponse); ok { | |
| 2011 | + | r0 = returnFunc(ctx, app) | |
| 2856 | 2012 | } else { | |
| 2857 | 2013 | if ret.Get(0) != nil { | |
| 2858 | - | r0 = ret.Get(0).(<-chan string) | |
| 2014 | + | r0 = ret.Get(0).(*dto.AppDeployResponse) | |
| 2859 | 2015 | } | |
| 2860 | 2016 | } | |
| 2861 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2862 | - | r1 = returnFunc(ctx, lines) | |
| 2017 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) string); ok { | |
| 2018 | + | r1 = returnFunc(ctx, app) | |
| 2863 | 2019 | } else { | |
| 2864 | - | r1 = ret.Error(1) | |
| 2020 | + | r1 = ret.Get(1).(string) | |
| 2865 | 2021 | } | |
| 2866 | - | return r0, r1 | |
| 2022 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 2023 | + | r2 = returnFunc(ctx, app) | |
| 2024 | + | } else { | |
| 2025 | + | r2 = ret.Error(2) | |
| 2026 | + | } | |
| 2027 | + | return r0, r1, r2 | |
| 2867 | 2028 | } | |
| 2868 | 2029 | ||
| 2869 | - | // MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs' | |
| 2870 | - | type MockControlPlane_StreamProcessLogs_Call struct { | |
| 2030 | + | // MockControlPlane_StopApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopApp' | |
| 2031 | + | type MockControlPlane_StopApp_Call struct { | |
| 2871 | 2032 | *mock.Call | |
| 2872 | 2033 | } | |
| 2873 | 2034 | ||
| 2874 | - | // StreamProcessLogs is a helper method to define mock.On call | |
| 2035 | + | // StopApp is a helper method to define mock.On call | |
| 2875 | 2036 | // - ctx context.Context | |
| 2876 | - | // - lines int | |
| 2877 | - | func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call { | |
| 2878 | - | return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)} | |
| 2037 | + | // - app string | |
| 2038 | + | func (_e *MockControlPlane_Expecter) StopApp(ctx any, app any) *MockControlPlane_StopApp_Call { | |
| 2039 | + | return &MockControlPlane_StopApp_Call{Call: _e.mock.On("StopApp", ctx, app)} | |
| 2879 | 2040 | } | |
| 2880 | 2041 | ||
| 2881 | - | func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockControlPlane_StreamProcessLogs_Call { | |
| 2042 | + | func (_c *MockControlPlane_StopApp_Call) Run(run func(ctx context.Context, app string)) *MockControlPlane_StopApp_Call { | |
| 2882 | 2043 | _c.Call.Run(func(args mock.Arguments) { | |
| 2883 | 2044 | var arg0 context.Context | |
| 2884 | 2045 | if args[0] != nil { | |
| 2885 | 2046 | arg0 = args[0].(context.Context) | |
| 2886 | 2047 | } | |
| 2887 | - | var arg1 int | |
| 2048 | + | var arg1 string | |
| 2888 | 2049 | if args[1] != nil { | |
| 2889 | - | arg1 = args[1].(int) | |
| 2050 | + | arg1 = args[1].(string) | |
| 2890 | 2051 | } | |
| 2891 | 2052 | run( | |
| 2892 | 2053 | arg0, | |
| @@ -2896,54 +2057,65 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Cont | |||
| 2896 | 2057 | return _c | |
| 2897 | 2058 | } | |
| 2898 | 2059 | ||
| 2899 | - | func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call { | |
| 2900 | - | _c.Call.Return(stringCh, err) | |
| 2060 | + | func (_c *MockControlPlane_StopApp_Call) Return(appDeployResponse *dto.AppDeployResponse, s string, err error) *MockControlPlane_StopApp_Call { | |
| 2061 | + | _c.Call.Return(appDeployResponse, s, err) | |
| 2901 | 2062 | return _c | |
| 2902 | 2063 | } | |
| 2903 | 2064 | ||
| 2904 | - | func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) (<-chan string, error)) *MockControlPlane_StreamProcessLogs_Call { | |
| 2065 | + | func (_c *MockControlPlane_StopApp_Call) RunAndReturn(run func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_StopApp_Call { | |
| 2905 | 2066 | _c.Call.Return(run) | |
| 2906 | 2067 | return _c | |
| 2907 | 2068 | } | |
| 2908 | 2069 | ||
| 2909 | - | // UpdateRoute provides a mock function for the type MockControlPlane | |
| 2910 | - | func (_mock *MockControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 2911 | - | ret := _mock.Called(ctx, route) | |
| 2070 | + | // StreamProcessLogs provides a mock function for the type MockControlPlane | |
| 2071 | + | func (_mock *MockControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 2072 | + | ret := _mock.Called(ctx, lines) | |
| 2912 | 2073 | ||
| 2913 | 2074 | if len(ret) == 0 { | |
| 2914 | - | panic("no return value specified for UpdateRoute") | |
| 2075 | + | panic("no return value specified for StreamProcessLogs") | |
| 2915 | 2076 | } | |
| 2916 | 2077 | ||
| 2917 | - | var r0 error | |
| 2918 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok { | |
| 2919 | - | r0 = returnFunc(ctx, route) | |
| 2078 | + | var r0 <-chan string | |
| 2079 | + | var r1 error | |
| 2080 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok { | |
| 2081 | + | return returnFunc(ctx, lines) | |
| 2082 | + | } | |
| 2083 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok { | |
| 2084 | + | r0 = returnFunc(ctx, lines) | |
| 2920 | 2085 | } else { | |
| 2921 | - | r0 = ret.Error(0) | |
| 2086 | + | if ret.Get(0) != nil { | |
| 2087 | + | r0 = ret.Get(0).(<-chan string) | |
| 2088 | + | } | |
| 2922 | 2089 | } | |
| 2923 | - | return r0 | |
| 2090 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2091 | + | r1 = returnFunc(ctx, lines) | |
| 2092 | + | } else { | |
| 2093 | + | r1 = ret.Error(1) | |
| 2094 | + | } | |
| 2095 | + | return r0, r1 | |
| 2924 | 2096 | } | |
| 2925 | 2097 | ||
| 2926 | - | // MockControlPlane_UpdateRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRoute' | |
| 2927 | - | type MockControlPlane_UpdateRoute_Call struct { | |
| 2098 | + | // MockControlPlane_StreamProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamProcessLogs' | |
| 2099 | + | type MockControlPlane_StreamProcessLogs_Call struct { | |
| 2928 | 2100 | *mock.Call | |
| 2929 | 2101 | } | |
| 2930 | 2102 | ||
| 2931 | - | // UpdateRoute is a helper method to define mock.On call | |
| 2103 | + | // StreamProcessLogs is a helper method to define mock.On call | |
| 2932 | 2104 | // - ctx context.Context | |
| 2933 | - | // - route domain.Route | |
| 2934 | - | func (_e *MockControlPlane_Expecter) UpdateRoute(ctx any, route any) *MockControlPlane_UpdateRoute_Call { | |
| 2935 | - | return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)} | |
| 2105 | + | // - lines int | |
| 2106 | + | func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call { | |
| 2107 | + | return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)} | |
| 2936 | 2108 | } | |
| 2937 | 2109 | ||
| 2938 | - | func (_c *MockControlPlane_UpdateRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockControlPlane_UpdateRoute_Call { | |
| 2110 | + | func (_c *MockControlPlane_StreamProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockControlPlane_StreamProcessLogs_Call { | |
| 2939 | 2111 | _c.Call.Run(func(args mock.Arguments) { | |
| 2940 | 2112 | var arg0 context.Context | |
| 2941 | 2113 | if args[0] != nil { | |
| 2942 | 2114 | arg0 = args[0].(context.Context) | |
| 2943 | 2115 | } | |
| 2944 | - | var arg1 domain.Route | |
| 2116 | + | var arg1 int | |
| 2945 | 2117 | if args[1] != nil { | |
| 2946 | - | arg1 = args[1].(domain.Route) | |
| 2118 | + | arg1 = args[1].(int) | |
| 2947 | 2119 | } | |
| 2948 | 2120 | run( | |
| 2949 | 2121 | arg0, | |
| @@ -2953,12 +2125,12 @@ func (_c *MockControlPlane_UpdateRoute_Call) Run(run func(ctx context.Context, r | |||
| 2953 | 2125 | return _c | |
| 2954 | 2126 | } | |
| 2955 | 2127 | ||
| 2956 | - | func (_c *MockControlPlane_UpdateRoute_Call) Return(err error) *MockControlPlane_UpdateRoute_Call { | |
| 2957 | - | _c.Call.Return(err) | |
| 2128 | + | func (_c *MockControlPlane_StreamProcessLogs_Call) Return(stringCh <-chan string, err error) *MockControlPlane_StreamProcessLogs_Call { | |
| 2129 | + | _c.Call.Return(stringCh, err) | |
| 2958 | 2130 | return _c | |
| 2959 | 2131 | } | |
| 2960 | 2132 | ||
| 2961 | - | func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockControlPlane_UpdateRoute_Call { | |
| 2133 | + | func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) (<-chan string, error)) *MockControlPlane_StreamProcessLogs_Call { | |
| 2962 | 2134 | _c.Call.Return(run) | |
| 2963 | 2135 | return _c | |
| 2964 | 2136 | } | |
M internal/adapters/in/cli/networks.go +1 -1
| @@ -37,7 +37,7 @@ Examples: | |||
| 37 | 37 | gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 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 | } | |
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 +67 -379
| @@ -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,47 +43,32 @@ 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 | 69 | gordon push myapp --build --remote ... | |
| 86 | - | gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ... | |
| 70 | + | gordon push myapp:v1.2.3 --tag v1.2.3 --remote ... | |
| 87 | 71 | gordon push registry.example.com/myapp:v1.2.3 --build --remote ... | |
| 88 | - | gordon push --domain myapp.example.com --build --remote ... | |
| 89 | 72 | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 90 | 73 | Args: cobra.MaximumNArgs(1), | |
| 91 | 74 | RunE: func(cmd *cobra.Command, args []string) error { | |
| @@ -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 +8 -2
| @@ -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...) | |
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
| @@ -2,8 +2,6 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | 5 | "io" | |
| 8 | 6 | "os" | |
| 9 | 7 | "os/exec" | |
| @@ -11,11 +9,7 @@ import ( | |||
| 11 | 9 | "strings" | |
| 12 | 10 | "testing" | |
| 13 | 11 | ||
| 14 | - | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 16 | - | "github.com/bnema/gordon/internal/domain" | |
| 17 | 12 | "github.com/stretchr/testify/assert" | |
| 18 | - | "github.com/stretchr/testify/mock" | |
| 19 | 13 | ) | |
| 20 | 14 | ||
| 21 | 15 | func TestValidateBuildArg(t *testing.T) { | |
| @@ -79,12 +73,12 @@ func TestClassifyPushArgument(t *testing.T) { | |||
| 79 | 73 | arg string | |
| 80 | 74 | want classifiedPushArg | |
| 81 | 75 | }{ | |
| 82 | - | {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}}, | |
| 83 | - | {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}}, | |
| 84 | - | {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp"}}, | |
| 85 | - | {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp@sha256:deadbeef"}}, | |
| 76 | + | {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:latest", repository: "myapp"}}, | |
| 77 | + | {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:v1.2.3", repository: "myapp"}}, | |
| 78 | + | {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp:v1.2.3", repository: "myapp"}}, | |
| 79 | + | {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp@sha256:deadbeef", repository: "myapp"}}, | |
| 86 | 80 | {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}}, | |
| 87 | - | {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}}, | |
| 81 | + | {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp", repository: "myapp"}}, | |
| 88 | 82 | } | |
| 89 | 83 | ||
| 90 | 84 | for _, tt := range tests { | |
| @@ -449,102 +443,3 @@ func TestParseLabelPair(t *testing.T) { | |||
| 449 | 443 | }) | |
| 450 | 444 | } | |
| 451 | 445 | } | |
| 452 | - | ||
| 453 | - | func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) { | |
| 454 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 455 | - | cpMock.EXPECT().FindRoutesByImage(context.Background(), "myapp").Return(nil, nil).Once() | |
| 456 | - | ||
| 457 | - | _, _, _, err := resolveFromImage(context.Background(), cpMock, "myapp", "Dockerfile") | |
| 458 | - | ||
| 459 | - | assert.Error(t, err) | |
| 460 | - | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | |
| 461 | - | assert.Contains(t, err.Error(), "gordon bootstrap") | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | func TestResolveRoute_DottedBareImageUsesImageLookup(t *testing.T) { | |
| 465 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 466 | - | cpMock.EXPECT().FindRoutesByImage(context.Background(), "my.app").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/my.app:latest"}}, nil).Once() | |
| 467 | - | ||
| 468 | - | registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "my.app", "", "Dockerfile") | |
| 469 | - | ||
| 470 | - | assert.NoError(t, err) | |
| 471 | - | assert.Equal(t, "registry.example.com", registry) | |
| 472 | - | assert.Equal(t, "my.app", imageName) | |
| 473 | - | assert.Equal(t, "app.example.com", pushDomain) | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | func TestResolveRoute_DottedBareImageNoRoutesKeepsBootstrapError(t *testing.T) { | |
| 477 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 478 | - | imageLookup := cpMock.EXPECT().FindRoutesByImage(context.Background(), "my.app").Return(nil, nil).Once() | |
| 479 | - | routeLookup := cpMock.EXPECT().GetRoute(context.Background(), "my.app").Return(nil, domain.ErrRouteNotFound).Once() | |
| 480 | - | mock.InOrder(imageLookup, routeLookup) | |
| 481 | - | ||
| 482 | - | _, _, _, err := resolveRoute(context.Background(), cpMock, "my.app", "", "Dockerfile") | |
| 483 | - | ||
| 484 | - | assert.Error(t, err) | |
| 485 | - | assert.True(t, errors.Is(err, domain.ErrNoRouteForImage)) | |
| 486 | - | assert.Contains(t, err.Error(), `no route configured for image "my.app"`) | |
| 487 | - | assert.Contains(t, err.Error(), "gordon bootstrap") | |
| 488 | - | assert.NotContains(t, err.Error(), "failed to get route for domain") | |
| 489 | - | } | |
| 490 | - | ||
| 491 | - | func TestNoRouteForImageErrorWrapsSentinel(t *testing.T) { | |
| 492 | - | err := noRouteForImageError("myapp") | |
| 493 | - | ||
| 494 | - | assert.True(t, errors.Is(err, domain.ErrNoRouteForImage)) | |
| 495 | - | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | func TestResolveRoute_DottedBareDomainFallsBackToLegacyLookup(t *testing.T) { | |
| 499 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 500 | - | imageLookup := cpMock.EXPECT().FindRoutesByImage(context.Background(), "app.example.com").Return(nil, nil).Once() | |
| 501 | - | routeLookup := cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}, nil).Once() | |
| 502 | - | mock.InOrder(imageLookup, routeLookup) | |
| 503 | - | ||
| 504 | - | registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "app.example.com", "", "Dockerfile") | |
| 505 | - | ||
| 506 | - | assert.NoError(t, err) | |
| 507 | - | assert.Equal(t, "registry.example.com", registry) | |
| 508 | - | assert.Equal(t, "myapp", imageName) | |
| 509 | - | assert.Equal(t, "app.example.com", pushDomain) | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | func TestResolveRoute_TaggedImageStripsTagBeforeLookup(t *testing.T) { | |
| 513 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 514 | - | cpMock.EXPECT().FindRoutesByImage(context.Background(), "myapp").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil).Once() | |
| 515 | - | ||
| 516 | - | registry, imageName, pushDomain, err := resolveRoute(context.Background(), cpMock, "myapp:v1.2.3", "", "Dockerfile") | |
| 517 | - | ||
| 518 | - | assert.NoError(t, err) | |
| 519 | - | assert.Equal(t, "registry.example.com", registry) | |
| 520 | - | assert.Equal(t, "myapp", imageName) | |
| 521 | - | assert.Equal(t, "app.example.com", pushDomain) | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | func TestResolveRoute_RegistryQualifiedTaggedImageUsesImageLookup(t *testing.T) { | |
| 525 | - | cpMock := climocks.NewMockControlPlane(t) | |
| 526 | - | cpMock.EXPECT().FindRoutesByImage(context.Background(), "registry.example.com/myapp").Return([]domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil).Once() | |
| 527 | - | ||
| 528 | - | _, _, _, err := resolveRoute(context.Background(), cpMock, "registry.example.com/myapp:v1.2.3", "", "Dockerfile") | |
| 529 | - | ||
| 530 | - | assert.NoError(t, err) | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | func TestIsInsufficientScope(t *testing.T) { | |
| 534 | - | tests := []struct { | |
| 535 | - | name string | |
| 536 | - | err error | |
| 537 | - | want bool | |
| 538 | - | }{ | |
| 539 | - | {"nil error", nil, false}, | |
| 540 | - | {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true}, | |
| 541 | - | {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true}, | |
| 542 | - | {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false}, | |
| 543 | - | {"plain error", fmt.Errorf("connection refused"), false}, | |
| 544 | - | } | |
| 545 | - | for _, tt := range tests { | |
| 546 | - | t.Run(tt.name, func(t *testing.T) { | |
| 547 | - | assert.Equal(t, tt.want, isInsufficientScope(tt.err)) | |
| 548 | - | }) | |
| 549 | - | } | |
| 550 | - | } | |
A internal/adapters/in/cli/remote/apps.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "net/url" | |
| 12 | + | ||
| 13 | + | "github.com/google/uuid" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Apps API: daemon-owned app mutations through the existing V2 | |
| 19 | + | // administration transport. Every mutation carries an Idempotency-Key | |
| 20 | + | // header (client-generated ULID); the daemon persists the claim before | |
| 21 | + | // effects and replays recorded results. On ambiguous transport outcome | |
| 22 | + | // the caller gets OutcomeUnknownError and must re-query by key — never | |
| 23 | + | // blindly retry. | |
| 24 | + | ||
| 25 | + | // newIdempotencyKey allocates a time-ordered key (uuid v7). | |
| 26 | + | func newIdempotencyKey() string { | |
| 27 | + | id, err := uuid.NewV7() | |
| 28 | + | if err != nil { | |
| 29 | + | id = uuid.New() | |
| 30 | + | } | |
| 31 | + | var buf [32]byte | |
| 32 | + | hex.Encode(buf[:], id[:]) | |
| 33 | + | return string(buf[:]) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // mutationPost sends a POST mutation with an idempotency key. | |
| 37 | + | func (c *Client) mutationPost(ctx context.Context, path string, key string, body any) (*http.Response, error) { | |
| 38 | + | var jsonBody []byte | |
| 39 | + | if body != nil { | |
| 40 | + | var err error | |
| 41 | + | jsonBody, err = json.Marshal(body) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return c.doMutation(ctx, http.MethodPost, path, key, jsonBody) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // doMutation executes one mutation request with the idempotency header. | |
| 50 | + | // Transport ambiguity surfaces as OutcomeUnknownError: the caller must | |
| 51 | + | // re-query GET …/operations/by-key/{key} before any retry, and retries | |
| 52 | + | // reuse the SAME key. | |
| 53 | + | func (c *Client) doMutation(ctx context.Context, method, path, key string, jsonBody []byte) (*http.Response, error) { | |
| 54 | + | reqURL := c.baseURL + "/admin" + path | |
| 55 | + | ||
| 56 | + | var bodyReader io.Reader | |
| 57 | + | if jsonBody != nil { | |
| 58 | + | bodyReader = bytes.NewReader(jsonBody) | |
| 59 | + | } | |
| 60 | + | req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 63 | + | } | |
| 64 | + | req.Header.Set("Content-Type", "application/json") | |
| 65 | + | req.Header.Set("Accept", "application/json") | |
| 66 | + | req.Header.Set("Idempotency-Key", key) | |
| 67 | + | ||
| 68 | + | bearer, err := c.bearerToken(ctx) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | if bearer != "" { | |
| 73 | + | req.Header.Set("Authorization", "Bearer "+bearer) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | resp, err := c.httpClient.Do(req) | |
| 77 | + | if err != nil { | |
| 78 | + | return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err} | |
| 79 | + | } | |
| 80 | + | if isRetryableStatus(resp.StatusCode) { | |
| 81 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize)) | |
| 82 | + | _ = resp.Body.Close() | |
| 83 | + | return nil, &OutcomeUnknownError{ | |
| 84 | + | Method: method, | |
| 85 | + | Path: path, | |
| 86 | + | Err: parseErrorResponse(resp, body), | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | return resp, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // ApplyApp validates and persists desired state (or dry-runs). | |
| 93 | + | func (c *Client) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) { | |
| 94 | + | resp, err := c.mutationPost(ctx, "/apps/apply", newIdempotencyKey(), req) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | var result dto.AppApplyResponse | |
| 99 | + | if err := parseResponse(resp, &result); err != nil { | |
| 100 | + | return nil, fmt.Errorf("apply app: %w", err) | |
| 101 | + | } | |
| 102 | + | return &result, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ListApps returns desired+active summaries. | |
| 106 | + | func (c *Client) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) { | |
| 107 | + | resp, err := c.request(ctx, http.MethodGet, "/apps", nil) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } | |
| 111 | + | var envelope struct { | |
| 112 | + | Apps []dto.AppSummaryDTO `json:"apps"` | |
| 113 | + | } | |
| 114 | + | if err := parseResponse(resp, &envelope); err != nil { | |
| 115 | + | return nil, fmt.Errorf("list apps: %w", err) | |
| 116 | + | } | |
| 117 | + | return envelope.Apps, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // ShowApp inspects desired + active + intent + op ref. | |
| 121 | + | func (c *Client) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | |
| 122 | + | resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app), nil) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | var result dto.AppShowResponse | |
| 127 | + | if err := parseResponse(resp, &result); err != nil { | |
| 128 | + | return nil, fmt.Errorf("show app %s: %w", app, err) | |
| 129 | + | } | |
| 130 | + | return &result, nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // DiffApp returns the normalized desired-vs-active diff. | |
| 134 | + | func (c *Client) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) { | |
| 135 | + | resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/diff", nil) | |
| 136 | + | if err != nil { | |
| 137 | + | return nil, err | |
| 138 | + | } | |
| 139 | + | var result dto.AppDiffResponse | |
| 140 | + | if err := parseResponse(resp, &result); err != nil { | |
| 141 | + | return nil, fmt.Errorf("diff app %s: %w", app, err) | |
| 142 | + | } | |
| 143 | + | return &result, nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // DeployApp activates a captured revision. It returns the key used so | |
| 147 | + | // ambiguous outcomes can be recovered via OperationByKey. | |
| 148 | + | func (c *Client) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | |
| 149 | + | key := newIdempotencyKey() | |
| 150 | + | resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/deploy", key, req) | |
| 151 | + | if err != nil { | |
| 152 | + | return nil, key, err | |
| 153 | + | } | |
| 154 | + | result, err := parseDeployResponse(resp, "deploy", app) | |
| 155 | + | return result, key, err | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // StopApp persists durable stopped intent and stops exact containers. | |
| 159 | + | func (c *Client) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 160 | + | return c.lifecyclePost(ctx, app, "stop") | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // StartApp clears stopped intent and ensures running from active. | |
| 164 | + | func (c *Client) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 165 | + | return c.lifecyclePost(ctx, app, "start") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // RestartApp restarts from pinned digests (no re-resolution). | |
| 169 | + | func (c *Client) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) { | |
| 170 | + | key := newIdempotencyKey() | |
| 171 | + | path := "/apps/" + url.PathEscape(app) + "/restart" | |
| 172 | + | if service != "" { | |
| 173 | + | path += "?service=" + url.QueryEscape(service) | |
| 174 | + | } | |
| 175 | + | resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, key, err | |
| 178 | + | } | |
| 179 | + | result, err := parseDeployResponse(resp, "restart", app) | |
| 180 | + | return result, key, err | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // RemoveApp withdraws workloads; volumes and secrets are retained. | |
| 184 | + | func (c *Client) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) { | |
| 185 | + | return c.lifecyclePost(ctx, app, "remove") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // lifecyclePost posts a body-less lifecycle mutation. | |
| 189 | + | func (c *Client) lifecyclePost(ctx context.Context, app, action string) (*dto.AppDeployResponse, string, error) { | |
| 190 | + | key := newIdempotencyKey() | |
| 191 | + | resp, err := c.doMutation(ctx, http.MethodPost, "/apps/"+url.PathEscape(app)+"/"+action, key, nil) | |
| 192 | + | if err != nil { | |
| 193 | + | return nil, key, err | |
| 194 | + | } | |
| 195 | + | result, err := parseDeployResponse(resp, action, app) | |
| 196 | + | return result, key, err | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // maxDeployConflictBodySize bounds 409 journal reads: journals carry | |
| 200 | + | // per-service results and steps and can exceed the 1 KB error cap. | |
| 201 | + | const maxDeployConflictBodySize int64 = 1 << 20 | |
| 202 | + | ||
| 203 | + | // AppOpConflictError reports a 409 deploy/lifecycle outcome while carrying | |
| 204 | + | // the decoded operation journal. Use errors.As to render the journal and | |
| 205 | + | // retain failure semantics; the idempotency key travels alongside the | |
| 206 | + | // returned error from DeployApp/RestartApp/lifecycle calls. | |
| 207 | + | type AppOpConflictError struct { | |
| 208 | + | StatusCode int | |
| 209 | + | Status string | |
| 210 | + | Response dto.AppDeployResponse | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (e *AppOpConflictError) Error() string { | |
| 214 | + | return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // parseDeployResponse decodes a deploy/lifecycle mutation response. A 409 | |
| 218 | + | // carries the journaled AppDeployResponse body (per-service results, | |
| 219 | + | // steps, effective/observed state) instead of an error envelope; it is | |
| 220 | + | // decoded and surfaced as AppOpConflictError so the CLI can render the | |
| 221 | + | // journal while retaining failure semantics. | |
| 222 | + | func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) { | |
| 223 | + | if resp.StatusCode == http.StatusConflict { | |
| 224 | + | return decodeDeployConflict(resp, action, app) | |
| 225 | + | } | |
| 226 | + | var result dto.AppDeployResponse | |
| 227 | + | if err := parseResponse(resp, &result); err != nil { | |
| 228 | + | return nil, fmt.Errorf("%s app %s: %w", action, app, err) | |
| 229 | + | } | |
| 230 | + | return &result, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // decodeDeployConflict decodes a 409 journal body into AppDeployResponse. | |
| 234 | + | // Bodies that are not journals (preflight AppError envelopes) fall back to | |
| 235 | + | // HTTPError so generic conflicts keep their existing shape. | |
| 236 | + | func decodeDeployConflict(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) { | |
| 237 | + | defer resp.Body.Close() | |
| 238 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, maxDeployConflictBodySize)) | |
| 239 | + | var result dto.AppDeployResponse | |
| 240 | + | if err := json.Unmarshal(body, &result); err != nil { | |
| 241 | + | return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body)) | |
| 242 | + | } | |
| 243 | + | if result.Op == "" && result.Outcome == "" && len(result.Services) == 0 && len(result.Steps) == 0 { | |
| 244 | + | return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body)) | |
| 245 | + | } | |
| 246 | + | conflict := &AppOpConflictError{StatusCode: resp.StatusCode, Status: resp.Status, Response: result} | |
| 247 | + | return &result, fmt.Errorf("%s app %s: %w", action, app, conflict) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // OperationByKey recovers an ambiguous mutation outcome by idempotency key. | |
| 251 | + | func (c *Client) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) { | |
| 252 | + | resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/operations/by-key/"+url.PathEscape(key), nil) | |
| 253 | + | if err != nil { | |
| 254 | + | return nil, err | |
| 255 | + | } | |
| 256 | + | var result dto.AppDeployResponse | |
| 257 | + | if err := parseResponse(resp, &result); err != nil { | |
| 258 | + | return nil, fmt.Errorf("operation by key: %w", err) | |
| 259 | + | } | |
| 260 | + | return &result, nil | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // SetAppSecrets writes secret values for pre-registered names. | |
| 264 | + | func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error { | |
| 265 | + | resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/set", newIdempotencyKey(), req) | |
| 266 | + | if err != nil { | |
| 267 | + | return err | |
| 268 | + | } | |
| 269 | + | return parseResponse(resp, nil) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // DeleteAppSecret removes one secret value (refused when referenced). | |
| 273 | + | func (c *Client) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error { | |
| 274 | + | resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/delete", newIdempotencyKey(), req) | |
| 275 | + | if err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | return parseResponse(resp, nil) | |
| 279 | + | } | |
A internal/adapters/in/cli/remote/apps_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | // Tests for the v2.50 app mutation transport (apps.go): every mutation | |
| 4 | + | // carries a client-generated Idempotency-Key, retryable gateway statuses | |
| 5 | + | // surface OutcomeUnknownError without replaying the mutation, and | |
| 6 | + | // ambiguous outcomes recover via the by-key endpoint. | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "encoding/json" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "sync/atomic" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func TestClientApplyApp_SendsIdempotencyKey(t *testing.T) { | |
| 23 | + | var gotKey string | |
| 24 | + | var gotBody dto.AppApplyRequest | |
| 25 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 26 | + | require.Equal(t, "/admin/apps/apply", r.URL.Path) | |
| 27 | + | require.Equal(t, http.MethodPost, r.Method) | |
| 28 | + | gotKey = r.Header.Get("Idempotency-Key") | |
| 29 | + | require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) | |
| 30 | + | w.Header().Set("Content-Type", "application/json") | |
| 31 | + | _ = json.NewEncoder(w).Encode(dto.AppApplyResponse{ | |
| 32 | + | App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1", | |
| 33 | + | }) | |
| 34 | + | })) | |
| 35 | + | defer srv.Close() | |
| 36 | + | ||
| 37 | + | resp, err := NewClient(srv.URL).ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "x"}) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | assert.Equal(t, "blog", resp.App) | |
| 40 | + | assert.NotEmpty(t, gotKey, "mutations must carry an Idempotency-Key") | |
| 41 | + | assert.Equal(t, "x", gotBody.ManifestTOML) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestClientDeployApp_GatewayErrorIsOutcomeUnknownWithoutReplay(t *testing.T) { | |
| 45 | + | var mutations int32 | |
| 46 | + | var gotKey string | |
| 47 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 48 | + | require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path) | |
| 49 | + | atomic.AddInt32(&mutations, 1) | |
| 50 | + | gotKey = r.Header.Get("Idempotency-Key") | |
| 51 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 52 | + | _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`)) | |
| 53 | + | })) | |
| 54 | + | defer srv.Close() | |
| 55 | + | ||
| 56 | + | resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{}) | |
| 57 | + | require.Nil(t, resp) | |
| 58 | + | require.Error(t, err) | |
| 59 | + | var unknown *OutcomeUnknownError | |
| 60 | + | require.ErrorAs(t, err, &unknown) | |
| 61 | + | assert.EqualValues(t, 1, atomic.LoadInt32(&mutations), "mutations must never replay on ambiguity") | |
| 62 | + | assert.NotEmpty(t, key) | |
| 63 | + | assert.Equal(t, gotKey, key, "recovery re-queries with the SAME key") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestClientOperationByKey_RecoveryPath(t *testing.T) { | |
| 67 | + | var gotPath string | |
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 69 | + | gotPath = r.URL.EscapedPath() | |
| 70 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 71 | + | w.Header().Set("Content-Type", "application/json") | |
| 72 | + | _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{ | |
| 73 | + | Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success", | |
| 74 | + | }) | |
| 75 | + | })) | |
| 76 | + | defer srv.Close() | |
| 77 | + | ||
| 78 | + | resp, err := NewClient(srv.URL).OperationByKey(context.Background(), "blog", "key-abc") | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, "op-1", resp.Op) | |
| 81 | + | assert.Equal(t, "/admin/apps/blog/operations/by-key/key-abc", gotPath) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestClientLifecycle_MutationsCarryKeys(t *testing.T) { | |
| 85 | + | var keys []string | |
| 86 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 87 | + | keys = append(keys, r.Header.Get("Idempotency-Key")) | |
| 88 | + | w.Header().Set("Content-Type", "application/json") | |
| 89 | + | _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"}) | |
| 90 | + | })) | |
| 91 | + | defer srv.Close() | |
| 92 | + | ||
| 93 | + | client := NewClient(srv.URL) | |
| 94 | + | ctx := context.Background() | |
| 95 | + | _, _, err := client.StopApp(ctx, "blog") | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | _, _, err = client.StartApp(ctx, "blog") | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | _, _, err = client.RemoveApp(ctx, "blog") | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | require.Len(t, keys, 3) | |
| 102 | + | for _, key := range keys { | |
| 103 | + | assert.NotEmpty(t, key) | |
| 104 | + | } | |
| 105 | + | assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) { | |
| 109 | + | journal := dto.AppDeployResponse{ | |
| 110 | + | Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", | |
| 111 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 112 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"}, | |
| 113 | + | }, | |
| 114 | + | Steps: []dto.AppStepDTO{{ID: "service.web.start", State: "failed", Error: "nope"}}, | |
| 115 | + | } | |
| 116 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 117 | + | require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path) | |
| 118 | + | w.Header().Set("Content-Type", "application/json") | |
| 119 | + | w.WriteHeader(http.StatusConflict) | |
| 120 | + | _ = json.NewEncoder(w).Encode(journal) | |
| 121 | + | })) | |
| 122 | + | defer srv.Close() | |
| 123 | + | ||
| 124 | + | resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{}) | |
| 125 | + | require.Error(t, err) | |
| 126 | + | require.NotNil(t, resp, "conflict must preserve the decoded journal") | |
| 127 | + | assert.Equal(t, "op-9", resp.Op) | |
| 128 | + | assert.Equal(t, "failed", resp.Services["web"].Result) | |
| 129 | + | assert.NotEmpty(t, key) | |
| 130 | + | var conflict *AppOpConflictError | |
| 131 | + | require.ErrorAs(t, err, &conflict) | |
| 132 | + | assert.Equal(t, http.StatusConflict, conflict.StatusCode) | |
| 133 | + | assert.Equal(t, "op-9", conflict.Response.Op) | |
| 134 | + | assert.Len(t, conflict.Response.Steps, 1) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestClientStopApp_ConflictDecodesJournal(t *testing.T) { | |
| 138 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 139 | + | require.Equal(t, "/admin/apps/blog/stop", r.URL.Path) | |
| 140 | + | w.Header().Set("Content-Type", "application/json") | |
| 141 | + | w.WriteHeader(http.StatusConflict) | |
| 142 | + | _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{ | |
| 143 | + | Op: "op-7", App: "blog", Outcome: "partial", | |
| 144 | + | Services: map[string]dto.AppServiceResultDTO{ | |
| 145 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"}, | |
| 146 | + | }, | |
| 147 | + | }) | |
| 148 | + | })) | |
| 149 | + | defer srv.Close() | |
| 150 | + | ||
| 151 | + | resp, key, err := NewClient(srv.URL).StopApp(context.Background(), "blog") | |
| 152 | + | require.Error(t, err) | |
| 153 | + | require.NotNil(t, resp) | |
| 154 | + | assert.Equal(t, "partial", resp.Outcome) | |
| 155 | + | assert.NotEmpty(t, key) | |
| 156 | + | var conflict *AppOpConflictError | |
| 157 | + | require.ErrorAs(t, err, &conflict) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestClientDeployApp_ConflictWithoutJournalFallsBackToHTTPError(t *testing.T) { | |
| 161 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 162 | + | w.WriteHeader(http.StatusConflict) | |
| 163 | + | _, _ = w.Write([]byte(`{"error":"state-conflict","message":"busy"}`)) | |
| 164 | + | })) | |
| 165 | + | defer srv.Close() | |
| 166 | + | ||
| 167 | + | resp, _, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{}) | |
| 168 | + | require.Error(t, err) | |
| 169 | + | require.Nil(t, resp) | |
| 170 | + | var httpErr *HTTPError | |
| 171 | + | require.ErrorAs(t, err, &httpErr) | |
| 172 | + | assert.Equal(t, http.StatusConflict, httpErr.StatusCode) | |
| 173 | + | } | |
M internal/adapters/in/cli/remote/client.go +47 -498
| @@ -478,210 +478,16 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body | |||
| 478 | 478 | return nil, fmt.Errorf("request failed after retries") | |
| 479 | 479 | } | |
| 480 | 480 | ||
| 481 | - | // Routes API | |
| 482 | - | ||
| 483 | - | // Type aliases for API types using shared DTO package. | |
| 484 | - | type RouteInfo = dto.RouteInfo | |
| 485 | - | type Attachment = dto.Attachment | |
| 486 | - | ||
| 487 | - | // ListRoutes returns all configured routes. | |
| 488 | - | func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) { | |
| 489 | - | resp, err := c.request(ctx, http.MethodGet, "/routes", nil) | |
| 490 | - | if err != nil { | |
| 491 | - | return nil, err | |
| 492 | - | } | |
| 493 | - | ||
| 494 | - | var result struct { | |
| 495 | - | Routes []domain.Route `json:"routes"` | |
| 496 | - | } | |
| 497 | - | if err := parseResponse(resp, &result); err != nil { | |
| 498 | - | return nil, err | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | return result.Routes, nil | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | // ListRoutesWithDetails returns routes with network and attachment info. | |
| 505 | - | func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) { | |
| 506 | - | resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil) | |
| 507 | - | if err != nil { | |
| 508 | - | return nil, err | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | var result struct { | |
| 512 | - | Routes []RouteInfo `json:"routes"` | |
| 513 | - | } | |
| 514 | - | if err := parseResponse(resp, &result); err != nil { | |
| 515 | - | return nil, err | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | return result.Routes, nil | |
| 519 | - | } | |
| 520 | - | ||
| 521 | - | // ListNetworks returns Gordon-managed networks. | |
| 522 | - | func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 523 | - | resp, err := c.request(ctx, http.MethodGet, "/networks", nil) | |
| 524 | - | if err != nil { | |
| 525 | - | return nil, err | |
| 526 | - | } | |
| 527 | - | ||
| 528 | - | var result struct { | |
| 529 | - | Networks []*domain.NetworkInfo `json:"networks"` | |
| 530 | - | } | |
| 531 | - | if err := parseResponse(resp, &result); err != nil { | |
| 532 | - | return nil, err | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | return result.Networks, nil | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | // ListAttachments returns attachments for a domain. | |
| 539 | - | func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) { | |
| 540 | - | if routeDomain == "" { | |
| 541 | - | return nil, fmt.Errorf("domain is required") | |
| 542 | - | } | |
| 543 | - | path := "/routes/" + url.PathEscape(routeDomain) + "/attachments" | |
| 544 | - | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| 545 | - | if err != nil { | |
| 546 | - | return nil, err | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | var result struct { | |
| 550 | - | Attachments []Attachment `json:"attachments"` | |
| 551 | - | } | |
| 552 | - | if err := parseResponse(resp, &result); err != nil { | |
| 553 | - | return nil, err | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | return result.Attachments, nil | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | // GetRoute returns a specific route by domain. | |
| 560 | - | func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 561 | - | resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil) | |
| 562 | - | if err != nil { | |
| 563 | - | return nil, err | |
| 564 | - | } | |
| 565 | - | ||
| 566 | - | var route domain.Route | |
| 567 | - | if err := parseResponse(resp, &route); err != nil { | |
| 568 | - | var httpErr *HTTPError | |
| 569 | - | if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { | |
| 570 | - | return nil, domain.ErrRouteNotFound | |
| 571 | - | } | |
| 572 | - | return nil, err | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | return &route, nil | |
| 576 | - | } | |
| 577 | - | ||
| 578 | - | // GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured. | |
| 579 | - | func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |
| 580 | - | resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil) | |
| 581 | - | if err != nil { | |
| 582 | - | return nil, err | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | var report domain.CleanupReport | |
| 586 | - | if err := parseResponse(resp, &report); err != nil { | |
| 587 | - | return nil, err | |
| 588 | - | } | |
| 589 | - | ||
| 590 | - | return &report, nil | |
| 591 | - | } | |
| 592 | - | ||
| 593 | - | // FindRoutesByImage returns all routes associated with the given image name. | |
| 594 | - | func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 595 | - | resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil) | |
| 596 | - | if err != nil { | |
| 597 | - | return nil, err | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | var result struct { | |
| 601 | - | Image string `json:"image"` | |
| 602 | - | Routes []domain.Route `json:"routes"` | |
| 603 | - | } | |
| 604 | - | if err := parseResponse(resp, &result); err != nil { | |
| 605 | - | return nil, err | |
| 606 | - | } | |
| 607 | - | ||
| 608 | - | return result.Routes, nil | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | // AddRoute adds a new route. | |
| 612 | - | func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | |
| 613 | - | resp, err := c.request(ctx, http.MethodPost, "/routes", route) | |
| 614 | - | if err != nil { | |
| 615 | - | return err | |
| 616 | - | } | |
| 617 | - | return parseResponse(resp, nil) | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | // UpdateRoute updates an existing route. | |
| 621 | - | func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 622 | - | resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route) | |
| 623 | - | if err != nil { | |
| 624 | - | return err | |
| 625 | - | } | |
| 626 | - | return parseResponse(resp, nil) | |
| 627 | - | } | |
| 628 | - | ||
| 629 | - | // RemoveRoute removes a route by domain. | |
| 630 | - | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 631 | - | _, err := c.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 632 | - | return err | |
| 633 | - | } | |
| 634 | - | ||
| 635 | - | // RemoveRouteWithCleanup removes a route and returns the server cleanup report. | |
| 636 | - | func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 637 | - | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) | |
| 638 | - | if err != nil { | |
| 639 | - | return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err) | |
| 640 | - | } | |
| 641 | - | var result dto.RouteDeleteResponse | |
| 642 | - | if err := parseResponse(resp, &result); err != nil { | |
| 643 | - | return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err) | |
| 644 | - | } | |
| 645 | - | return &result, nil | |
| 646 | - | } | |
| 647 | - | ||
| 648 | - | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 649 | - | resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req) | |
| 650 | - | if err != nil { | |
| 651 | - | return nil, err | |
| 652 | - | } | |
| 653 | - | var result dto.BootstrapResponse | |
| 654 | - | if err := parseResponse(resp, &result); err != nil { | |
| 655 | - | return nil, err | |
| 656 | - | } | |
| 657 | - | return &result, nil | |
| 658 | - | } | |
| 659 | - | ||
| 660 | 481 | // Secrets API | |
| 661 | 482 | ||
| 662 | - | // AttachmentSecrets represents secrets for an attachment container. | |
| 663 | - | type AttachmentSecrets struct { | |
| 664 | - | Service string `json:"service"` | |
| 665 | - | Keys []string `json:"keys"` | |
| 666 | - | } | |
| 667 | - | ||
| 668 | - | // SecretsListResult contains domain secrets and any attachment secrets. | |
| 483 | + | // SecretsListResult contains domain secret keys. | |
| 669 | 484 | type SecretsListResult struct { | |
| 670 | - | Domain string `json:"domain"` | |
| 671 | - | Keys []string `json:"keys"` | |
| 672 | - | Attachments []AttachmentSecrets `json:"attachments,omitempty"` | |
| 485 | + | Domain string `json:"domain"` | |
| 486 | + | Keys []string `json:"keys"` | |
| 673 | 487 | } | |
| 674 | 488 | ||
| 675 | - | // ListSecrets returns the list of secret keys for a domain. | |
| 676 | - | func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) { | |
| 677 | - | result, err := c.ListSecretsWithAttachments(ctx, secretDomain) | |
| 678 | - | if err != nil { | |
| 679 | - | return nil, err | |
| 680 | - | } | |
| 681 | - | return result.Keys, nil | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | // ListSecretsWithAttachments returns domain secrets and attachment secrets. | |
| 489 | + | // ListSecretsWithAttachments returns domain secret keys. Attachment | |
| 490 | + | // containers are retired: the daemon answers without attachment keys. | |
| 685 | 491 | func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) { | |
| 686 | 492 | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil) | |
| 687 | 493 | if err != nil { | |
| @@ -714,40 +520,18 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err | |||
| 714 | 520 | return parseResponse(resp, nil) | |
| 715 | 521 | } | |
| 716 | 522 | ||
| 717 | - | // SetAttachmentSecrets sets secrets for an attachment container. | |
| 718 | - | func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error { | |
| 719 | - | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) | |
| 720 | - | resp, err := c.request(ctx, http.MethodPost, path, secrets) | |
| 721 | - | if err != nil { | |
| 722 | - | return err | |
| 723 | - | } | |
| 724 | - | return parseResponse(resp, nil) | |
| 725 | - | } | |
| 726 | - | ||
| 727 | - | // DeleteAttachmentSecret removes a secret from an attachment container. | |
| 728 | - | func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error { | |
| 729 | - | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key) | |
| 730 | - | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |
| 731 | - | if err != nil { | |
| 732 | - | return err | |
| 733 | - | } | |
| 734 | - | return parseResponse(resp, nil) | |
| 735 | - | } | |
| 736 | - | ||
| 737 | 523 | // Status API | |
| 738 | 524 | ||
| 739 | 525 | // Status represents the Gordon server status. | |
| 740 | 526 | type Status struct { | |
| 741 | - | Routes int `json:"routes"` | |
| 527 | + | Apps int `json:"apps"` | |
| 742 | 528 | RegistryDomain string `json:"registry_domain"` | |
| 743 | 529 | RegistryPort int `json:"registry_port"` | |
| 744 | 530 | ServerPort int `json:"server_port"` | |
| 745 | - | AutoRoute bool `json:"auto_route"` | |
| 746 | 531 | NetworkIsolation bool `json:"network_isolation"` | |
| 747 | 532 | ContainerStatus map[string]string `json:"container_status"` | |
| 748 | 533 | } | |
| 749 | 534 | ||
| 750 | - | // GetTLSStatus returns the public TLS/ACME status. | |
| 751 | 535 | func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 752 | 536 | resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil) | |
| 753 | 537 | if err != nil { | |
| @@ -840,10 +624,10 @@ func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*d | |||
| 840 | 624 | } | |
| 841 | 625 | ||
| 842 | 626 | // ListBackups returns backups globally or for a domain. | |
| 843 | - | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 627 | + | func (c *Client) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) { | |
| 844 | 628 | path := "/backups" | |
| 845 | - | if backupDomain != "" { | |
| 846 | - | path += "/" + url.PathEscape(backupDomain) | |
| 629 | + | if app != "" { | |
| 630 | + | path += "/" + url.PathEscape(app) | |
| 847 | 631 | } | |
| 848 | 632 | ||
| 849 | 633 | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| @@ -859,7 +643,8 @@ func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.Ba | |||
| 859 | 643 | return result.Backups, nil | |
| 860 | 644 | } | |
| 861 | 645 | ||
| 862 | - | // BackupStatus returns aggregate backup status. | |
| 646 | + | // BackupStatus returns stored backups plus declared targets that have no | |
| 647 | + | // completed backup yet. | |
| 863 | 648 | func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 864 | 649 | resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil) | |
| 865 | 650 | if err != nil { | |
| @@ -874,13 +659,15 @@ func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |||
| 874 | 659 | return result.Backups, nil | |
| 875 | 660 | } | |
| 876 | 661 | ||
| 877 | - | // RunBackup triggers a backup for a domain. | |
| 878 | - | func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 879 | - | if backupDomain == "" { | |
| 880 | - | return nil, fmt.Errorf("domain cannot be empty") | |
| 662 | + | // RunBackup runs one declared database backup of one app. service and | |
| 663 | + | // database are explicit selectors. | |
| 664 | + | func (c *Client) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) { | |
| 665 | + | if app == "" { | |
| 666 | + | return nil, fmt.Errorf("app cannot be empty") | |
| 881 | 667 | } | |
| 882 | 668 | ||
| 883 | - | resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName}) | |
| 669 | + | resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(app), | |
| 670 | + | dto.BackupRunRequest{Service: service, Database: database}) | |
| 884 | 671 | if err != nil { | |
| 885 | 672 | return nil, err | |
| 886 | 673 | } | |
| @@ -893,30 +680,11 @@ func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*d | |||
| 893 | 680 | return &result, nil | |
| 894 | 681 | } | |
| 895 | 682 | ||
| 896 | - | // DetectDatabases detects supported databases for a domain. | |
| 897 | - | func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 898 | - | if backupDomain == "" { | |
| 899 | - | return nil, fmt.Errorf("domain cannot be empty") | |
| 900 | - | } | |
| 901 | - | ||
| 902 | - | resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil) | |
| 903 | - | if err != nil { | |
| 904 | - | return nil, err | |
| 905 | - | } | |
| 906 | - | ||
| 907 | - | var result dto.BackupDetectResponse | |
| 908 | - | if err := parseResponse(resp, &result); err != nil { | |
| 909 | - | return nil, err | |
| 910 | - | } | |
| 911 | - | ||
| 912 | - | return result.Databases, nil | |
| 913 | - | } | |
| 914 | - | ||
| 915 | - | // ListVolumeBackups returns volume backups globally or for a domain. | |
| 916 | - | func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) { | |
| 683 | + | // ListVolumeBackups returns volume backups globally or for one app. | |
| 684 | + | func (c *Client) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) { | |
| 917 | 685 | path := "/backups/volumes" | |
| 918 | - | if backupDomain != "" { | |
| 919 | - | path += "/" + url.PathEscape(backupDomain) | |
| 686 | + | if app != "" { | |
| 687 | + | path += "/" + url.PathEscape(app) | |
| 920 | 688 | } | |
| 921 | 689 | ||
| 922 | 690 | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| @@ -945,14 +713,15 @@ func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, | |||
| 945 | 713 | return result.Backups, nil | |
| 946 | 714 | } | |
| 947 | 715 | ||
| 948 | - | // RunVolumeBackups triggers volume backups. | |
| 949 | - | func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) { | |
| 950 | - | path := "/backups/volumes" | |
| 951 | - | if backupDomain != "" { | |
| 952 | - | path += "/" + url.PathEscape(backupDomain) | |
| 716 | + | // RunVolumeBackups runs one declared volume backup of one app. service | |
| 717 | + | // and volume are explicit selectors. | |
| 718 | + | func (c *Client) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) { | |
| 719 | + | if app == "" { | |
| 720 | + | return nil, fmt.Errorf("app cannot be empty") | |
| 953 | 721 | } | |
| 722 | + | path := "/backups/volumes/" + url.PathEscape(app) | |
| 954 | 723 | ||
| 955 | - | resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName}) | |
| 724 | + | resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Service: service, Volume: volume}) | |
| 956 | 725 | if err != nil { | |
| 957 | 726 | return nil, err | |
| 958 | 727 | } | |
| @@ -1006,9 +775,6 @@ type Config struct { | |||
| 1006 | 775 | RegistryDomain string `json:"registry_domain"` | |
| 1007 | 776 | DataDir string `json:"data_dir,omitempty"` | |
| 1008 | 777 | } `json:"server"` | |
| 1009 | - | AutoRoute struct { | |
| 1010 | - | Enabled bool `json:"enabled"` | |
| 1011 | - | } `json:"auto_route"` | |
| 1012 | 778 | NetworkIsolation struct { | |
| 1013 | 779 | Enabled bool `json:"enabled"` | |
| 1014 | 780 | Prefix string `json:"prefix"` | |
| @@ -1018,7 +784,6 @@ type Config struct { | |||
| 1018 | 784 | Prefix string `json:"prefix"` | |
| 1019 | 785 | Preserve bool `json:"preserve"` | |
| 1020 | 786 | } `json:"volumes"` | |
| 1021 | - | Routes []domain.Route `json:"routes"` | |
| 1022 | 787 | ExternalRoutes []ExternalRoute `json:"external_routes"` | |
| 1023 | 788 | } | |
| 1024 | 789 | ||
| @@ -1029,97 +794,43 @@ type ExternalRoute struct { | |||
| 1029 | 794 | } | |
| 1030 | 795 | ||
| 1031 | 796 | // GetConfig returns the Gordon configuration. | |
| 1032 | - | func (c *Client) GetConfig(ctx context.Context) (*Config, error) { | |
| 1033 | - | resp, err := c.request(ctx, http.MethodGet, "/config", nil) | |
| 797 | + | func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 798 | + | resp, err := c.request(ctx, http.MethodGet, "/networks", nil) | |
| 1034 | 799 | if err != nil { | |
| 1035 | 800 | return nil, err | |
| 1036 | 801 | } | |
| 1037 | 802 | ||
| 1038 | - | var config Config | |
| 1039 | - | if err := parseResponse(resp, &config); err != nil { | |
| 1040 | - | return nil, err | |
| 1041 | - | } | |
| 1042 | - | ||
| 1043 | - | return &config, nil | |
| 1044 | - | } | |
| 1045 | - | ||
| 1046 | - | // Ping checks if the remote Gordon instance is reachable. | |
| 1047 | - | func (c *Client) Ping(ctx context.Context) error { | |
| 1048 | - | _, err := c.GetStatus(ctx) | |
| 1049 | - | return err | |
| 1050 | - | } | |
| 1051 | - | ||
| 1052 | - | // Deploy API | |
| 1053 | - | ||
| 1054 | - | // DeployResult contains the result of a deployment. | |
| 1055 | - | type DeployResult struct { | |
| 1056 | - | Status string `json:"status"` | |
| 1057 | - | ContainerID string `json:"container_id"` | |
| 1058 | - | Domain string `json:"domain"` | |
| 1059 | - | } | |
| 1060 | - | ||
| 1061 | - | // Deploy triggers a deployment for the specified domain. | |
| 1062 | - | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | |
| 1063 | - | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) | |
| 1064 | - | if err != nil { | |
| 1065 | - | return nil, err | |
| 803 | + | var result struct { | |
| 804 | + | Networks []*domain.NetworkInfo `json:"networks"` | |
| 1066 | 805 | } | |
| 1067 | - | ||
| 1068 | - | var result DeployResult | |
| 1069 | 806 | if err := parseResponse(resp, &result); err != nil { | |
| 1070 | 807 | return nil, err | |
| 1071 | 808 | } | |
| 1072 | 809 | ||
| 1073 | - | return &result, nil | |
| 1074 | - | } | |
| 1075 | - | ||
| 1076 | - | // DeployIntent tells the server that a CLI-managed push is about to happen, | |
| 1077 | - | // suppressing event-based deploys for this image. | |
| 1078 | - | func (c *Client) DeployIntent(ctx context.Context, imageName string) error { | |
| 1079 | - | imageName = strings.TrimSpace(imageName) | |
| 1080 | - | if imageName == "" { | |
| 1081 | - | return fmt.Errorf("image name cannot be empty") | |
| 1082 | - | } | |
| 1083 | - | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil) | |
| 1084 | - | if err != nil { | |
| 1085 | - | return err | |
| 1086 | - | } | |
| 1087 | - | return parseResponse(resp, nil) | |
| 1088 | - | } | |
| 1089 | - | ||
| 1090 | - | // Restart API | |
| 1091 | - | ||
| 1092 | - | // RestartResult contains the result of a restart. | |
| 1093 | - | type RestartResult struct { | |
| 1094 | - | Status string `json:"status"` | |
| 1095 | - | Domain string `json:"domain"` | |
| 810 | + | return result.Networks, nil | |
| 1096 | 811 | } | |
| 1097 | 812 | ||
| 1098 | - | // Restart triggers a container restart for the specified domain. | |
| 1099 | - | func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) { | |
| 1100 | - | if restartDomain == "" { | |
| 1101 | - | return nil, fmt.Errorf("domain cannot be empty") | |
| 1102 | - | } | |
| 1103 | - | path := "/restart/" + url.PathEscape(restartDomain) | |
| 1104 | - | if withAttachments { | |
| 1105 | - | path += "?attachments=true" | |
| 1106 | - | } | |
| 1107 | - | resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil) | |
| 813 | + | // GetConfig returns the Gordon configuration. | |
| 814 | + | func (c *Client) GetConfig(ctx context.Context) (*Config, error) { | |
| 815 | + | resp, err := c.request(ctx, http.MethodGet, "/config", nil) | |
| 1108 | 816 | if err != nil { | |
| 1109 | 817 | return nil, err | |
| 1110 | 818 | } | |
| 1111 | 819 | ||
| 1112 | - | var result RestartResult | |
| 1113 | - | if err := parseResponse(resp, &result); err != nil { | |
| 820 | + | var config Config | |
| 821 | + | if err := parseResponse(resp, &config); err != nil { | |
| 1114 | 822 | return nil, err | |
| 1115 | 823 | } | |
| 1116 | 824 | ||
| 1117 | - | return &result, nil | |
| 825 | + | return &config, nil | |
| 1118 | 826 | } | |
| 1119 | 827 | ||
| 1120 | - | // Tags API | |
| 828 | + | // Ping checks if the remote Gordon instance is reachable. | |
| 829 | + | func (c *Client) Ping(ctx context.Context) error { | |
| 830 | + | _, err := c.GetStatus(ctx) | |
| 831 | + | return err | |
| 832 | + | } | |
| 1121 | 833 | ||
| 1122 | - | // ListTags returns available tags for a repository. | |
| 1123 | 834 | func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 1124 | 835 | if repository == "" { | |
| 1125 | 836 | return nil, fmt.Errorf("repository cannot be empty") | |
| @@ -1194,169 +905,6 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line | |||
| 1194 | 905 | // Attachments Config API | |
| 1195 | 906 | ||
| 1196 | 907 | // ListOrphanedAttachments returns running attachment containers no longer configured. | |
| 1197 | - | func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 1198 | - | resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil) | |
| 1199 | - | if err != nil { | |
| 1200 | - | return nil, err | |
| 1201 | - | } | |
| 1202 | - | var result struct { | |
| 1203 | - | Attachments []domain.CleanupAttachment `json:"attachments"` | |
| 1204 | - | } | |
| 1205 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1206 | - | return nil, err | |
| 1207 | - | } | |
| 1208 | - | return result.Attachments, nil | |
| 1209 | - | } | |
| 1210 | - | ||
| 1211 | - | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |
| 1212 | - | func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 1213 | - | path := "/attachments/prune" | |
| 1214 | - | params := url.Values{} | |
| 1215 | - | if stop { | |
| 1216 | - | params.Set("stop", "true") | |
| 1217 | - | } | |
| 1218 | - | if owner != "" { | |
| 1219 | - | params.Set("owner", owner) | |
| 1220 | - | } | |
| 1221 | - | if encoded := params.Encode(); encoded != "" { | |
| 1222 | - | path += "?" + encoded | |
| 1223 | - | } | |
| 1224 | - | resp, err := c.request(ctx, http.MethodPost, path, nil) | |
| 1225 | - | if err != nil { | |
| 1226 | - | return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err) | |
| 1227 | - | } | |
| 1228 | - | var result domain.CleanupReport | |
| 1229 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1230 | - | return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err) | |
| 1231 | - | } | |
| 1232 | - | return &result, nil | |
| 1233 | - | } | |
| 1234 | - | ||
| 1235 | - | // GetAllAttachmentsConfig returns all configured attachments. | |
| 1236 | - | func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 1237 | - | resp, err := c.request(ctx, http.MethodGet, "/attachments", nil) | |
| 1238 | - | if err != nil { | |
| 1239 | - | return nil, err | |
| 1240 | - | } | |
| 1241 | - | ||
| 1242 | - | var result struct { | |
| 1243 | - | Attachments map[string][]string `json:"attachments"` | |
| 1244 | - | } | |
| 1245 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1246 | - | return nil, err | |
| 1247 | - | } | |
| 1248 | - | ||
| 1249 | - | return result.Attachments, nil | |
| 1250 | - | } | |
| 1251 | - | ||
| 1252 | - | // GetAttachmentsConfig returns attachments for a specific domain/group from config. | |
| 1253 | - | func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 1254 | - | if domainOrGroup == "" { | |
| 1255 | - | return nil, fmt.Errorf("domain or group is required") | |
| 1256 | - | } | |
| 1257 | - | path := "/attachments/" + url.PathEscape(domainOrGroup) | |
| 1258 | - | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| 1259 | - | if err != nil { | |
| 1260 | - | return nil, err | |
| 1261 | - | } | |
| 1262 | - | ||
| 1263 | - | var result struct { | |
| 1264 | - | Target string `json:"target"` | |
| 1265 | - | Images []string `json:"images"` | |
| 1266 | - | } | |
| 1267 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1268 | - | return nil, err | |
| 1269 | - | } | |
| 1270 | - | ||
| 1271 | - | return result.Images, nil | |
| 1272 | - | } | |
| 1273 | - | ||
| 1274 | - | // FindAttachmentTargetsByImage returns all attachment targets associated with the given image name. | |
| 1275 | - | func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 1276 | - | resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil) | |
| 1277 | - | if err != nil { | |
| 1278 | - | return nil, err | |
| 1279 | - | } | |
| 1280 | - | ||
| 1281 | - | var result dto.AttachmentTargetsByImageResponse | |
| 1282 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1283 | - | return nil, err | |
| 1284 | - | } | |
| 1285 | - | ||
| 1286 | - | return result.Targets, nil | |
| 1287 | - | } | |
| 1288 | - | ||
| 1289 | - | // AddAttachment adds an attachment to a domain/group. | |
| 1290 | - | func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 1291 | - | if domainOrGroup == "" { | |
| 1292 | - | return fmt.Errorf("domain or group is required") | |
| 1293 | - | } | |
| 1294 | - | if image == "" { | |
| 1295 | - | return fmt.Errorf("image is required") | |
| 1296 | - | } | |
| 1297 | - | path := "/attachments/" + url.PathEscape(domainOrGroup) | |
| 1298 | - | resp, err := c.request(ctx, http.MethodPost, path, struct { | |
| 1299 | - | Image string `json:"image"` | |
| 1300 | - | }{Image: image}) | |
| 1301 | - | if err != nil { | |
| 1302 | - | return err | |
| 1303 | - | } | |
| 1304 | - | return parseResponse(resp, nil) | |
| 1305 | - | } | |
| 1306 | - | ||
| 1307 | - | // RemoveAttachment removes an attachment from a domain/group. | |
| 1308 | - | func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 1309 | - | if domainOrGroup == "" { | |
| 1310 | - | return fmt.Errorf("domain or group is required") | |
| 1311 | - | } | |
| 1312 | - | if image == "" { | |
| 1313 | - | return fmt.Errorf("image is required") | |
| 1314 | - | } | |
| 1315 | - | path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image) | |
| 1316 | - | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |
| 1317 | - | if err != nil { | |
| 1318 | - | return err | |
| 1319 | - | } | |
| 1320 | - | return parseResponse(resp, nil) | |
| 1321 | - | } | |
| 1322 | - | ||
| 1323 | - | func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) { | |
| 1324 | - | resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil) | |
| 1325 | - | if err != nil { | |
| 1326 | - | return nil, err | |
| 1327 | - | } | |
| 1328 | - | ||
| 1329 | - | var result dto.AutoRouteAllowedDomainsResponse | |
| 1330 | - | if err := parseResponse(resp, &result); err != nil { | |
| 1331 | - | return nil, err | |
| 1332 | - | } | |
| 1333 | - | ||
| 1334 | - | return result.Domains, nil | |
| 1335 | - | } | |
| 1336 | - | ||
| 1337 | - | func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 1338 | - | if strings.TrimSpace(pattern) == "" { | |
| 1339 | - | return fmt.Errorf("pattern must not be empty") | |
| 1340 | - | } | |
| 1341 | - | resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern}) | |
| 1342 | - | if err != nil { | |
| 1343 | - | return err | |
| 1344 | - | } | |
| 1345 | - | return parseResponse(resp, nil) | |
| 1346 | - | } | |
| 1347 | - | ||
| 1348 | - | func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error { | |
| 1349 | - | if strings.TrimSpace(pattern) == "" { | |
| 1350 | - | return fmt.Errorf("pattern must not be empty") | |
| 1351 | - | } | |
| 1352 | - | resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil) | |
| 1353 | - | if err != nil { | |
| 1354 | - | return err | |
| 1355 | - | } | |
| 1356 | - | return parseResponse(resp, nil) | |
| 1357 | - | } | |
| 1358 | - | ||
| 1359 | - | // openSSEStream opens an SSE connection to the given admin path and returns the response. | |
| 1360 | 908 | func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) { | |
| 1361 | 909 | streamURL := c.baseURL + "/admin" + path | |
| 1362 | 910 | ||
| @@ -1377,7 +925,8 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response | |||
| 1377 | 925 | // Use the same transport as the main client (honoring TLS config and | |
| 1378 | 926 | // custom transports) but without a timeout so streaming doesn't get cut off. | |
| 1379 | 927 | streamClient := &http.Client{ | |
| 1380 | - | Transport: c.httpClient.Transport, | |
| 928 | + | Transport: c.httpClient.Transport, | |
| 929 | + | CheckRedirect: c.httpClient.CheckRedirect, | |
| 1381 | 930 | } | |
| 1382 | 931 | resp, err := streamClient.Do(req) | |
| 1383 | 932 | if err != nil { | |
M internal/adapters/in/cli/remote/client_test.go +8 -167
| @@ -17,7 +17,6 @@ import ( | |||
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 20 | - | "github.com/bnema/gordon/internal/domain" | |
| 21 | 20 | ) | |
| 22 | 21 | ||
| 23 | 22 | func withFastRetry(t *testing.T) { | |
| @@ -32,50 +31,6 @@ func withFastRetry(t *testing.T) { | |||
| 32 | 31 | }) | |
| 33 | 32 | } | |
| 34 | 33 | ||
| 35 | - | func TestClientRestartDoesNotRetryGatewayResponseAfterSideEffect(t *testing.T) { | |
| 36 | - | withFastRetry(t) | |
| 37 | - | ||
| 38 | - | var mutations int32 | |
| 39 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 40 | - | require.Equal(t, "/admin/restart/test.example.com", r.URL.Path) | |
| 41 | - | require.Equal(t, http.MethodPost, r.Method) | |
| 42 | - | atomic.AddInt32(&mutations, 1) | |
| 43 | - | w.WriteHeader(http.StatusServiceUnavailable) | |
| 44 | - | _, _ = w.Write([]byte(`{"error":"upstream response unavailable"}`)) | |
| 45 | - | })) | |
| 46 | - | defer srv.Close() | |
| 47 | - | ||
| 48 | - | client := NewClient(srv.URL) | |
| 49 | - | result, err := client.Restart(context.Background(), "test.example.com", false) | |
| 50 | - | ||
| 51 | - | require.Nil(t, result) | |
| 52 | - | var outcomeErr *OutcomeUnknownError | |
| 53 | - | require.ErrorAs(t, err, &outcomeErr) | |
| 54 | - | assert.Contains(t, err.Error(), "inspect current state before retrying") | |
| 55 | - | assert.EqualValues(t, 1, atomic.LoadInt32(&mutations)) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func TestClientRestartDoesNotRetryAmbiguousTransportFailure(t *testing.T) { | |
| 59 | - | withFastRetry(t) | |
| 60 | - | ||
| 61 | - | var mutations int32 | |
| 62 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 63 | - | atomic.AddInt32(&mutations, 1) | |
| 64 | - | conn, _, err := w.(http.Hijacker).Hijack() | |
| 65 | - | require.NoError(t, err) | |
| 66 | - | require.NoError(t, conn.Close()) | |
| 67 | - | })) | |
| 68 | - | defer srv.Close() | |
| 69 | - | ||
| 70 | - | client := NewClient(srv.URL) | |
| 71 | - | result, err := client.Restart(context.Background(), "test.example.com", true) | |
| 72 | - | ||
| 73 | - | require.Nil(t, result) | |
| 74 | - | var outcomeErr *OutcomeUnknownError | |
| 75 | - | require.ErrorAs(t, err, &outcomeErr) | |
| 76 | - | assert.EqualValues(t, 1, atomic.LoadInt32(&mutations)) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | 34 | func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) { | |
| 80 | 35 | withFastRetry(t) | |
| 81 | 36 | ||
| @@ -97,73 +52,6 @@ func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) { | |||
| 97 | 52 | assert.EqualValues(t, 1, atomic.LoadInt32(&attempts)) | |
| 98 | 53 | } | |
| 99 | 54 | ||
| 100 | - | func TestClientDeployDoesNotRetryGatewayResponse(t *testing.T) { | |
| 101 | - | withFastRetry(t) | |
| 102 | - | ||
| 103 | - | var attempts int32 | |
| 104 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 105 | - | require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path) | |
| 106 | - | require.Equal(t, http.MethodPost, r.Method) | |
| 107 | - | atomic.AddInt32(&attempts, 1) | |
| 108 | - | w.WriteHeader(http.StatusBadGateway) | |
| 109 | - | _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`)) | |
| 110 | - | })) | |
| 111 | - | defer srv.Close() | |
| 112 | - | ||
| 113 | - | client := NewClient(srv.URL) | |
| 114 | - | result, err := client.Deploy(context.Background(), "test.example.com") | |
| 115 | - | ||
| 116 | - | require.Nil(t, result) | |
| 117 | - | var outcomeErr *OutcomeUnknownError | |
| 118 | - | require.ErrorAs(t, err, &outcomeErr) | |
| 119 | - | assert.ErrorContains(t, err, "502 Bad Gateway: upstream unavailable") | |
| 120 | - | assert.EqualValues(t, 1, atomic.LoadInt32(&attempts)) | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | func TestClientDeployConfirmedRejectionIsNotOutcomeUnknown(t *testing.T) { | |
| 124 | - | var attempts int32 | |
| 125 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 126 | - | atomic.AddInt32(&attempts, 1) | |
| 127 | - | w.WriteHeader(http.StatusUnprocessableEntity) | |
| 128 | - | _, _ = w.Write([]byte(`{"error":"invalid deployment"}`)) | |
| 129 | - | })) | |
| 130 | - | defer srv.Close() | |
| 131 | - | ||
| 132 | - | client := NewClient(srv.URL) | |
| 133 | - | result, err := client.Deploy(context.Background(), "test.example.com") | |
| 134 | - | ||
| 135 | - | require.Nil(t, result) | |
| 136 | - | var outcomeErr *OutcomeUnknownError | |
| 137 | - | assert.NotErrorAs(t, err, &outcomeErr) | |
| 138 | - | var httpErr *HTTPError | |
| 139 | - | require.ErrorAs(t, err, &httpErr) | |
| 140 | - | assert.Equal(t, http.StatusUnprocessableEntity, httpErr.StatusCode) | |
| 141 | - | assert.EqualValues(t, 1, atomic.LoadInt32(&attempts)) | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | func TestClientDeployAuthenticationRejectionIsNotOutcomeUnknown(t *testing.T) { | |
| 145 | - | var deployAttempts int32 | |
| 146 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 147 | - | if r.URL.Path == "/auth/token" { | |
| 148 | - | w.WriteHeader(http.StatusForbidden) | |
| 149 | - | _, _ = w.Write([]byte(`{"error":"invalid credentials"}`)) | |
| 150 | - | return | |
| 151 | - | } | |
| 152 | - | atomic.AddInt32(&deployAttempts, 1) | |
| 153 | - | w.WriteHeader(http.StatusNoContent) | |
| 154 | - | })) | |
| 155 | - | defer srv.Close() | |
| 156 | - | ||
| 157 | - | client := NewClient(srv.URL, WithToken("invalid-token")) | |
| 158 | - | result, err := client.Deploy(context.Background(), "test.example.com") | |
| 159 | - | ||
| 160 | - | require.Nil(t, result) | |
| 161 | - | var outcomeErr *OutcomeUnknownError | |
| 162 | - | assert.NotErrorAs(t, err, &outcomeErr) | |
| 163 | - | assert.ErrorContains(t, err, "ephemeral token exchange: 403 Forbidden") | |
| 164 | - | assert.Zero(t, atomic.LoadInt32(&deployAttempts)) | |
| 165 | - | } | |
| 166 | - | ||
| 167 | 55 | func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) { | |
| 168 | 56 | withFastRetry(t) | |
| 169 | 57 | ||
| @@ -197,7 +85,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) { | |||
| 197 | 85 | return | |
| 198 | 86 | } | |
| 199 | 87 | w.Header().Set("Content-Type", "application/json") | |
| 200 | - | _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`)) | |
| 88 | + | _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`)) | |
| 201 | 89 | })) | |
| 202 | 90 | defer srv.Close() | |
| 203 | 91 | ||
| @@ -206,7 +94,7 @@ func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) { | |||
| 206 | 94 | ||
| 207 | 95 | require.NoError(t, err) | |
| 208 | 96 | require.NotNil(t, status) | |
| 209 | - | assert.Equal(t, 2, status.Routes) | |
| 97 | + | assert.Equal(t, 2, status.Apps) | |
| 210 | 98 | assert.EqualValues(t, 3, atomic.LoadInt32(&attempts)) | |
| 211 | 99 | } | |
| 212 | 100 | ||
| @@ -222,7 +110,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) { | |||
| 222 | 110 | return | |
| 223 | 111 | } | |
| 224 | 112 | w.Header().Set("Content-Type", "application/json") | |
| 225 | - | _, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`)) | |
| 113 | + | _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`)) | |
| 226 | 114 | })) | |
| 227 | 115 | defer srv.Close() | |
| 228 | 116 | ||
| @@ -231,7 +119,7 @@ func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) { | |||
| 231 | 119 | ||
| 232 | 120 | require.NoError(t, err) | |
| 233 | 121 | require.NotNil(t, status) | |
| 234 | - | assert.Equal(t, 2, status.Routes) | |
| 122 | + | assert.Equal(t, 2, status.Apps) | |
| 235 | 123 | assert.EqualValues(t, 3, atomic.LoadInt32(&attempts)) | |
| 236 | 124 | } | |
| 237 | 125 | ||
| @@ -259,7 +147,7 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 259 | 147 | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 260 | 148 | require.Equal(t, "/admin/status", r.URL.Path) | |
| 261 | 149 | w.Header().Set("Content-Type", "application/json") | |
| 262 | - | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |
| 150 | + | _, _ = w.Write([]byte(`{"apps":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |
| 263 | 151 | })) | |
| 264 | 152 | defer srv.Close() | |
| 265 | 153 | ||
| @@ -355,53 +243,6 @@ func TestClientPruneImages(t *testing.T) { | |||
| 355 | 243 | assert.Equal(t, 3, resp.Registry.TagsRemoved) | |
| 356 | 244 | } | |
| 357 | 245 | ||
| 358 | - | func TestClientFindAttachmentTargetsByImage(t *testing.T) { | |
| 359 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 360 | - | require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path) | |
| 361 | - | require.Equal(t, "", r.URL.RawQuery) | |
| 362 | - | require.Equal(t, http.MethodGet, r.Method) | |
| 363 | - | w.Header().Set("Content-Type", "application/json") | |
| 364 | - | _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`)) | |
| 365 | - | })) | |
| 366 | - | defer srv.Close() | |
| 367 | - | ||
| 368 | - | client := NewClient(srv.URL) | |
| 369 | - | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16") | |
| 370 | - | require.NoError(t, err) | |
| 371 | - | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) { | |
| 375 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 376 | - | require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path) | |
| 377 | - | require.Equal(t, http.MethodGet, r.Method) | |
| 378 | - | w.WriteHeader(http.StatusNotFound) | |
| 379 | - | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |
| 380 | - | })) | |
| 381 | - | defer srv.Close() | |
| 382 | - | ||
| 383 | - | client := NewClient(srv.URL) | |
| 384 | - | route, err := client.GetRoute(context.Background(), "missing.example.test") | |
| 385 | - | require.Nil(t, route) | |
| 386 | - | require.Error(t, err) | |
| 387 | - | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | |
| 391 | - | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 392 | - | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | |
| 393 | - | require.Equal(t, http.MethodGet, r.Method) | |
| 394 | - | w.Header().Set("Content-Type", "application/json") | |
| 395 | - | _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`)) | |
| 396 | - | })) | |
| 397 | - | defer srv.Close() | |
| 398 | - | ||
| 399 | - | client := NewClient(srv.URL) | |
| 400 | - | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag") | |
| 401 | - | require.NoError(t, err) | |
| 402 | - | assert.Equal(t, []string{"workers"}, targets) | |
| 403 | - | } | |
| 404 | - | ||
| 405 | 246 | func TestParseResponse_CapsErrorBodySize(t *testing.T) { | |
| 406 | 247 | largeBody := strings.Repeat("x", 10*1024*1024) | |
| 407 | 248 | resp := &http.Response{ | |
| @@ -589,20 +430,20 @@ func TestParseErrorResponse_NonJSON(t *testing.T) { | |||
| 589 | 430 | ||
| 590 | 431 | func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) { | |
| 591 | 432 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 592 | - | require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path) | |
| 433 | + | require.Equal(t, "/admin/backups/volumes/shop", r.URL.Path) | |
| 593 | 434 | require.Equal(t, http.MethodPost, r.Method) | |
| 594 | 435 | w.Header().Set("Content-Type", "application/json") | |
| 595 | 436 | w.WriteHeader(http.StatusPartialContent) | |
| 596 | 437 | require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{ | |
| 597 | 438 | Status: "partial", | |
| 598 | - | Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}}, | |
| 439 | + | Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: "completed"}}, | |
| 599 | 440 | Error: "one volume failed", | |
| 600 | 441 | })) | |
| 601 | 442 | })) | |
| 602 | 443 | defer srv.Close() | |
| 603 | 444 | ||
| 604 | 445 | client := NewClient(srv.URL) | |
| 605 | - | result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "") | |
| 446 | + | result, err := client.RunVolumeBackups(context.Background(), "shop", "api", "data") | |
| 606 | 447 | ||
| 607 | 448 | require.Error(t, err) | |
| 608 | 449 | assert.Contains(t, err.Error(), "one volume failed") | |
A internal/adapters/in/cli/remote/local.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io/fs" | |
| 8 | + | "net" | |
| 9 | + | "net/http" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/localadmin" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // LocalBaseURL is the synthetic origin of the Unix-socket admin client. The | |
| 19 | + | // transport ignores the host and always dials the owner-only socket. | |
| 20 | + | const LocalBaseURL = "http://gordon.local" | |
| 21 | + | ||
| 22 | + | // ErrDaemonUnavailable reports that no owner-only admin socket passed | |
| 23 | + | // validation, so no local daemon can be reached. | |
| 24 | + | var ErrDaemonUnavailable = errors.New("daemon-unavailable") | |
| 25 | + | ||
| 26 | + | // errLocalRedirect refuses redirects on the local socket: a redirect can never | |
| 27 | + | // be a legitimate part of the owner-only admin surface. | |
| 28 | + | var errLocalRedirect = errors.New("local admin client does not follow redirects") | |
| 29 | + | ||
| 30 | + | // localSocketDirs lists the discovery candidates in daemon priority order. | |
| 31 | + | // It is a variable so tests can isolate from a real daemon. | |
| 32 | + | var localSocketDirs = localadmin.ClientDirCandidates | |
| 33 | + | ||
| 34 | + | // DiscoverLocalSocket returns the first admin socket candidate, in daemon | |
| 35 | + | // priority order, that is an owner-owned Unix socket without group/other | |
| 36 | + | // permission bits. | |
| 37 | + | func DiscoverLocalSocket() (string, error) { | |
| 38 | + | return DiscoverLocalSocketIn(localSocketDirs()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // DiscoverLocalSocketIn is DiscoverLocalSocket over explicit candidate | |
| 42 | + | // directories. Each candidate is validated, never followed through symlinks. | |
| 43 | + | func DiscoverLocalSocketIn(dirs []string) (string, error) { | |
| 44 | + | var lastErr error | |
| 45 | + | for _, dir := range dirs { | |
| 46 | + | path := filepath.Join(dir, localadmin.SocketName) | |
| 47 | + | if _, err := os.Lstat(path); err != nil { | |
| 48 | + | lastErr = err | |
| 49 | + | continue | |
| 50 | + | } | |
| 51 | + | if err := localadmin.ValidateDir(dir); err != nil { | |
| 52 | + | return "", fmt.Errorf("%w: unsafe local admin directory: %w", ErrDaemonUnavailable, err) | |
| 53 | + | } | |
| 54 | + | if err := localadmin.ValidateSocket(path); err != nil { | |
| 55 | + | return "", fmt.Errorf("%w: unsafe local admin socket: %w", ErrDaemonUnavailable, err) | |
| 56 | + | } | |
| 57 | + | live, err := localadmin.ProbeSocket(path) | |
| 58 | + | if err != nil { | |
| 59 | + | return "", fmt.Errorf("%w: probe local admin socket: %w", ErrDaemonUnavailable, err) | |
| 60 | + | } | |
| 61 | + | if !live { | |
| 62 | + | lastErr = syscall.ECONNREFUSED | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | return path, nil | |
| 66 | + | } | |
| 67 | + | if lastErr == nil { | |
| 68 | + | lastErr = fs.ErrNotExist | |
| 69 | + | } | |
| 70 | + | return "", fmt.Errorf("%w: no owner-only admin socket found: %w", ErrDaemonUnavailable, lastErr) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // NewLocalClient discovers the daemon's owner-only admin socket and returns a | |
| 74 | + | // client bound to it. It returns ErrDaemonUnavailable when no daemon is | |
| 75 | + | // reachable; callers must not fall back to local writes. | |
| 76 | + | func NewLocalClient() (*Client, error) { | |
| 77 | + | socketPath, err := DiscoverLocalSocket() | |
| 78 | + | if err != nil { | |
| 79 | + | return nil, err | |
| 80 | + | } | |
| 81 | + | return NewLocalClientForSocket(socketPath), nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // NewLocalClientForSocket returns a client whose transport dials only the | |
| 85 | + | // given Unix socket. It sends no Authorization header and never exchanges a | |
| 86 | + | // token, so it works with auth.enabled=false. | |
| 87 | + | func NewLocalClientForSocket(socketPath string) *Client { | |
| 88 | + | transport := &http.Transport{ | |
| 89 | + | // Never consult environment proxies for the local socket. DialContext | |
| 90 | + | // authenticates SO_PEERCRED before the transport can write request bytes. | |
| 91 | + | Proxy: nil, | |
| 92 | + | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { | |
| 93 | + | return localadmin.DialContext(ctx, socketPath) | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | httpClient := &http.Client{ | |
| 98 | + | Transport: transport, | |
| 99 | + | Timeout: 2 * time.Minute, | |
| 100 | + | CheckRedirect: func(*http.Request, []*http.Request) error { | |
| 101 | + | return errLocalRedirect | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | return NewClient(LocalBaseURL, WithHTTPClient(httpClient)) | |
| 106 | + | } | |
A internal/adapters/in/cli/remote/local_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/localadmin" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func mustListenUnix(t *testing.T, dir string, handler http.Handler) string { | |
| 20 | + | t.Helper() | |
| 21 | + | ln, _, err := localadmin.Listen(dir) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | ||
| 24 | + | srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} | |
| 25 | + | go func() { _ = srv.Serve(ln) }() | |
| 26 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 27 | + | ||
| 28 | + | return localadmin.SocketPath(dir) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestLocalAdminDiscoveryRejectsUnsafeSelectedCandidate(t *testing.T) { | |
| 32 | + | unsafeDir := t.TempDir() | |
| 33 | + | goodDir := t.TempDir() | |
| 34 | + | ||
| 35 | + | unsafeLn, _, err := localadmin.Listen(unsafeDir) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | t.Cleanup(func() { _ = unsafeLn.Close() }) | |
| 38 | + | require.NoError(t, os.Chmod(localadmin.SocketPath(unsafeDir), 0o666)) | |
| 39 | + | ||
| 40 | + | goodLn, _, err := localadmin.Listen(goodDir) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | t.Cleanup(func() { _ = goodLn.Close() }) | |
| 43 | + | ||
| 44 | + | missingDir := t.TempDir() | |
| 45 | + | ||
| 46 | + | _, err = DiscoverLocalSocketIn([]string{missingDir, unsafeDir, goodDir}) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | assert.ErrorIs(t, err, ErrDaemonUnavailable) | |
| 49 | + | assert.ErrorIs(t, err, localadmin.ErrUnsafePath) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestLocalAdminDiscoveryReportsUnavailable(t *testing.T) { | |
| 53 | + | dir := t.TempDir() | |
| 54 | + | _, err := DiscoverLocalSocketIn([]string{dir}) | |
| 55 | + | require.Error(t, err) | |
| 56 | + | assert.ErrorIs(t, err, ErrDaemonUnavailable) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestLocalAdminDiscoveryUsesXDGCandidates(t *testing.T) { | |
| 60 | + | xdg := t.TempDir() | |
| 61 | + | t.Setenv("XDG_RUNTIME_DIR", xdg) | |
| 62 | + | ||
| 63 | + | ln, _, err := localadmin.Listen(filepath.Join(xdg, "gordon")) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | t.Cleanup(func() { _ = ln.Close() }) | |
| 66 | + | ||
| 67 | + | got, err := DiscoverLocalSocket() | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.Equal(t, localadmin.SocketPath(filepath.Join(xdg, "gordon")), got) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestLocalAdminClientFailsWithoutSocket(t *testing.T) { | |
| 73 | + | dirs := []string{t.TempDir(), t.TempDir()} | |
| 74 | + | restore := localSocketDirs | |
| 75 | + | localSocketDirs = func() []string { return dirs } | |
| 76 | + | t.Cleanup(func() { localSocketDirs = restore }) | |
| 77 | + | ||
| 78 | + | _, err := NewLocalClient() | |
| 79 | + | require.Error(t, err) | |
| 80 | + | assert.ErrorIs(t, err, ErrDaemonUnavailable) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // TestLocalAdminClientSendsNoTokenAndNoExchange proves tokenless local | |
| 84 | + | // requests: no Authorization header and no /auth/token call. | |
| 85 | + | func TestLocalAdminClientSendsNoTokenAndNoExchange(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | type captured struct { | |
| 88 | + | path string | |
| 89 | + | auth string | |
| 90 | + | method string | |
| 91 | + | } | |
| 92 | + | requests := make(chan captured, 4) | |
| 93 | + | ||
| 94 | + | socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 95 | + | requests <- captured{path: r.URL.Path, auth: r.Header.Get("Authorization"), method: r.Method} | |
| 96 | + | w.Header().Set("Content-Type", "application/json") | |
| 97 | + | _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}}) | |
| 98 | + | })) | |
| 99 | + | ||
| 100 | + | client := NewLocalClientForSocket(socketPath) | |
| 101 | + | apps, err := client.ListApps(context.Background()) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | assert.Empty(t, apps) | |
| 104 | + | ||
| 105 | + | select { | |
| 106 | + | case got := <-requests: | |
| 107 | + | assert.Equal(t, "/admin/apps", got.path) | |
| 108 | + | assert.Empty(t, got.auth) | |
| 109 | + | case <-time.After(2 * time.Second): | |
| 110 | + | t.Fatal("no request captured") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | select { | |
| 114 | + | case extra := <-requests: | |
| 115 | + | t.Fatalf("unexpected extra request: %+v", extra) | |
| 116 | + | case <-time.After(100 * time.Millisecond): | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestLocalAdminClientIgnoresEnvironmentProxy(t *testing.T) { | |
| 121 | + | dir := t.TempDir() | |
| 122 | + | requests := make(chan string, 1) | |
| 123 | + | ||
| 124 | + | socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 125 | + | requests <- r.URL.Path | |
| 126 | + | w.Header().Set("Content-Type", "application/json") | |
| 127 | + | _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}}) | |
| 128 | + | })) | |
| 129 | + | ||
| 130 | + | t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") | |
| 131 | + | t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") | |
| 132 | + | ||
| 133 | + | client := NewLocalClientForSocket(socketPath) | |
| 134 | + | _, err := client.ListApps(context.Background()) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | ||
| 137 | + | select { | |
| 138 | + | case got := <-requests: | |
| 139 | + | assert.Equal(t, "/admin/apps", got) | |
| 140 | + | case <-time.After(2 * time.Second): | |
| 141 | + | t.Fatal("no request captured") | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestLocalAdminClientRejectsRedirects(t *testing.T) { | |
| 146 | + | dir := t.TempDir() | |
| 147 | + | socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 148 | + | if r.URL.Path == "/admin/apps" { | |
| 149 | + | http.Redirect(w, r, "http://evil.example/admin/apps", http.StatusFound) | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | w.WriteHeader(http.StatusTeapot) | |
| 153 | + | })) | |
| 154 | + | ||
| 155 | + | client := NewLocalClientForSocket(socketPath) | |
| 156 | + | _, err := client.ListApps(context.Background()) | |
| 157 | + | require.Error(t, err) | |
| 158 | + | assert.Contains(t, err.Error(), "redirect") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestLocalAdminClientRespectsCancellation(t *testing.T) { | |
| 162 | + | dir := t.TempDir() | |
| 163 | + | release := make(chan struct{}) | |
| 164 | + | socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 165 | + | <-release | |
| 166 | + | })) | |
| 167 | + | ||
| 168 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 169 | + | done := make(chan error, 1) | |
| 170 | + | go func() { | |
| 171 | + | client := NewLocalClientForSocket(socketPath) | |
| 172 | + | _, err := client.ListApps(ctx) | |
| 173 | + | done <- err | |
| 174 | + | }() | |
| 175 | + | ||
| 176 | + | cancel() | |
| 177 | + | select { | |
| 178 | + | case err := <-done: | |
| 179 | + | require.Error(t, err) | |
| 180 | + | assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), "got %v", err) | |
| 181 | + | case <-time.After(3 * time.Second): | |
| 182 | + | t.Fatal("request did not honor cancellation") | |
| 183 | + | } | |
| 184 | + | close(release) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestLocalAdminClientFailsOnMissingSocket(t *testing.T) { | |
| 188 | + | client := NewLocalClientForSocket(filepath.Join(t.TempDir(), "absent.sock")) | |
| 189 | + | _, err := client.ListApps(context.Background()) | |
| 190 | + | require.Error(t, err) | |
| 191 | + | ||
| 192 | + | var transportErr *requestTransportError | |
| 193 | + | assert.True(t, errors.As(err, &transportErr), "expected transport error, got %T: %v", err, err) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestLocalAdminTransportDialsUnixOnly(t *testing.T) { | |
| 197 | + | dir := t.TempDir() | |
| 198 | + | socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 199 | + | w.WriteHeader(http.StatusNoContent) | |
| 200 | + | })) | |
| 201 | + | ||
| 202 | + | // The synthetic base URL host must never be resolved: only the socket is dialed. | |
| 203 | + | client := NewLocalClientForSocket(socketPath) | |
| 204 | + | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, LocalBaseURL+"/admin/apps", nil) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | resp, err := client.httpClient.Do(req) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | defer resp.Body.Close() | |
| 209 | + | assert.Equal(t, http.StatusNoContent, resp.StatusCode) | |
| 210 | + | } | |
D internal/adapters/in/cli/remote/preview.go +0 -70
| @@ -1,70 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | "net/url" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/domain" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // ListPreviews returns all active preview environments. | |
| 13 | - | func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) { | |
| 14 | - | resp, err := c.request(ctx, http.MethodGet, "/previews", nil) | |
| 15 | - | if err != nil { | |
| 16 | - | return nil, err | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | var result struct { | |
| 20 | - | Previews []domain.PreviewRoute `json:"previews"` | |
| 21 | - | } | |
| 22 | - | if err := parseResponse(resp, &result); err != nil { | |
| 23 | - | return nil, err | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | return result.Previews, nil | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // DeletePreview tears down a preview environment by name. | |
| 30 | - | func (c *Client) DeletePreview(ctx context.Context, name string) error { | |
| 31 | - | if name == "" { | |
| 32 | - | return fmt.Errorf("preview name cannot be empty") | |
| 33 | - | } | |
| 34 | - | resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil) | |
| 35 | - | if err != nil { | |
| 36 | - | return err | |
| 37 | - | } | |
| 38 | - | return parseResponse(resp, nil) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // ExtendPreview extends the TTL of a preview environment. | |
| 42 | - | func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error { | |
| 43 | - | if name == "" { | |
| 44 | - | return fmt.Errorf("preview name cannot be empty") | |
| 45 | - | } | |
| 46 | - | if ttl == "" { | |
| 47 | - | return fmt.Errorf("ttl cannot be empty") | |
| 48 | - | } | |
| 49 | - | resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct { | |
| 50 | - | TTL string `json:"ttl"` | |
| 51 | - | }{TTL: ttl}) | |
| 52 | - | if err != nil { | |
| 53 | - | return err | |
| 54 | - | } | |
| 55 | - | return parseResponse(resp, nil) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // GetPreview returns a single preview by name. | |
| 59 | - | func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) { | |
| 60 | - | previews, err := c.ListPreviews(ctx) | |
| 61 | - | if err != nil { | |
| 62 | - | return nil, fmt.Errorf("list previews: %w", err) | |
| 63 | - | } | |
| 64 | - | for _, p := range previews { | |
| 65 | - | if p.Name == name { | |
| 66 | - | return &p, nil | |
| 67 | - | } | |
| 68 | - | } | |
| 69 | - | return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |
| 70 | - | } | |
M internal/adapters/in/cli/remote_inference.go +90 -104
| @@ -5,20 +5,17 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "strconv" | |
| 8 | 10 | "strings" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 11 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 12 | - | "github.com/bnema/gordon/pkg/validation" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error) | |
| 16 | 17 | ||
| 17 | - | func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) { | |
| 18 | - | if domainFlag != "" { | |
| 19 | - | return inferRemoteForRouteDomain(ctx, domainFlag) | |
| 20 | - | } | |
| 21 | - | ||
| 18 | + | func inferPushRemote(ctx context.Context, imageArg, _, dockerfile string) (*remote.ResolvedRemote, error) { | |
| 22 | 19 | if imageArg == "" { | |
| 23 | 20 | detected, err := detectImageName(dockerfile) | |
| 24 | 21 | if err != nil { | |
| @@ -26,105 +23,20 @@ func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile strin | |||
| 26 | 23 | } | |
| 27 | 24 | imageArg = detected | |
| 28 | 25 | } | |
| 29 | - | ||
| 30 | - | if looksLikeLegacyDomain(imageArg) { | |
| 31 | - | lookupImage := imageArg | |
| 32 | - | domainLookupArg := imageArg | |
| 33 | - | if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg { | |
| 34 | - | domainLookupArg = parsedImage | |
| 35 | - | if !strings.HasPrefix(ref, "sha256:") { | |
| 36 | - | lookupImage = parsedImage | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | resolved, err := inferRemoteForImage(ctx, lookupImage) | |
| 41 | - | if err != nil || resolved != nil { | |
| 42 | - | return resolved, err | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return inferRemoteForRouteDomain(ctx, domainLookupArg) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 26 | classified := classifyPushArgument(imageArg) | |
| 49 | - | return inferRemoteForImage(ctx, classified.lookupImage) | |
| 27 | + | return inferRemoteForImage(ctx, classified.repository) | |
| 50 | 28 | } | |
| 51 | 29 | ||
| 52 | 30 | func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |
| 53 | 31 | return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 54 | - | routes, err := client.FindRoutesByImage(ctx, imageName) | |
| 55 | - | if err != nil { | |
| 56 | - | return false, err | |
| 57 | - | } | |
| 58 | - | return len(filterPreviewRoutes(routes)) > 0, nil | |
| 59 | - | }) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) { | |
| 63 | - | return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 64 | - | _, err := client.GetRoute(ctx, routeDomain) | |
| 65 | - | if err != nil { | |
| 66 | - | if isRemoteNotFoundError(err) { | |
| 67 | - | return false, nil | |
| 68 | - | } | |
| 69 | - | return false, err | |
| 70 | - | } | |
| 71 | - | return true, nil | |
| 72 | - | }) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func inferRemoteForRouteCleanupDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) { | |
| 76 | - | return inferSavedRemote(ctx, "route cleanup", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 77 | - | _, err := client.GetRoute(ctx, routeDomain) | |
| 78 | - | if err == nil { | |
| 79 | - | return true, nil | |
| 80 | - | } | |
| 81 | - | if !isRemoteNotFoundError(err) { | |
| 82 | - | return false, err | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | preview, err := client.GetRouteCleanupPreview(ctx, routeDomain) | |
| 86 | - | if err != nil { | |
| 87 | - | return false, err | |
| 88 | - | } | |
| 89 | - | return routeCleanupReportHasEvidence(preview), nil | |
| 90 | - | }) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func routeCleanupReportHasEvidence(report *domain.CleanupReport) bool { | |
| 94 | - | if report == nil { | |
| 95 | - | return false | |
| 96 | - | } | |
| 97 | - | return len(report.PreservedVolumes) > 0 || len(report.PreservedAttachments) > 0 || len(report.OrphanedEntities) > 0 | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |
| 101 | - | return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 102 | - | targets, err := client.FindAttachmentTargetsByImage(ctx, imageName) | |
| 103 | - | if err != nil { | |
| 104 | - | return false, err | |
| 105 | - | } | |
| 106 | - | return len(targets) > 0, nil | |
| 107 | - | }) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) { | |
| 111 | - | return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 112 | - | images, err := client.GetAttachmentsConfig(ctx, target) | |
| 113 | - | if err == nil { | |
| 114 | - | return len(images) > 0, nil | |
| 115 | - | } | |
| 116 | - | if !isRemoteNotFoundError(err) { | |
| 117 | - | return false, err | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | _, err = client.GetRoute(ctx, target) | |
| 32 | + | tags, err := client.ListTags(ctx, imageName) | |
| 121 | 33 | if err != nil { | |
| 122 | 34 | if isRemoteNotFoundError(err) { | |
| 123 | 35 | return false, nil | |
| 124 | 36 | } | |
| 125 | 37 | return false, err | |
| 126 | 38 | } | |
| 127 | - | return true, nil | |
| 39 | + | return len(tags) > 0, nil | |
| 128 | 40 | }) | |
| 129 | 41 | } | |
| 130 | 42 | ||
| @@ -153,7 +65,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo | |||
| 153 | 65 | ||
| 154 | 66 | for _, name := range names { | |
| 155 | 67 | entry := remotes[name] | |
| 156 | - | matched, err := probe(ctx, newRoutesTargetClient(name, entry)) | |
| 68 | + | matched, err := probe(ctx, newInferenceTargetClient(name, entry)) | |
| 157 | 69 | if err != nil { | |
| 158 | 70 | probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err)) | |
| 159 | 71 | continue | |
| @@ -183,7 +95,7 @@ func inferSavedRemote(ctx context.Context, targetKind, target string, probe remo | |||
| 183 | 95 | } | |
| 184 | 96 | ||
| 185 | 97 | func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) { | |
| 186 | - | if _, ok := resolveRoutesExplicitTarget(); ok { | |
| 98 | + | if _, ok := resolveExplicitTargetName(); ok { | |
| 187 | 99 | return nil, false | |
| 188 | 100 | } | |
| 189 | 101 | ||
| @@ -205,8 +117,8 @@ func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.Reso | |||
| 205 | 117 | return &remote.ResolvedRemote{ | |
| 206 | 118 | Name: name, | |
| 207 | 119 | URL: entry.URL, | |
| 208 | - | Token: resolveRoutesTokenForTarget(name, entry), | |
| 209 | - | InsecureTLS: resolveRoutesInsecureForTarget(name, entry), | |
| 120 | + | Token: resolveTokenForTarget(name, entry), | |
| 121 | + | InsecureTLS: resolveInsecureForTarget(name, entry), | |
| 210 | 122 | } | |
| 211 | 123 | } | |
| 212 | 124 | ||
| @@ -217,12 +129,86 @@ func isRemoteNotFoundError(err error) bool { | |||
| 217 | 129 | return errors.Is(err, domain.ErrRouteNotFound) | |
| 218 | 130 | } | |
| 219 | 131 | ||
| 220 | - | func filterPreviewRoutes(routes []domain.Route) []domain.Route { | |
| 221 | - | filtered := make([]domain.Route, 0, len(routes)) | |
| 222 | - | for _, route := range routes { | |
| 223 | - | if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) { | |
| 224 | - | filtered = append(filtered, route) | |
| 132 | + | func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) { | |
| 133 | + | target, ok := resolveExplicitTargetName() | |
| 134 | + | if !ok { | |
| 135 | + | return nil, false, nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { | |
| 139 | + | return &remote.ResolvedRemote{ | |
| 140 | + | URL: target, | |
| 141 | + | Token: resolveTokenForTarget("", remote.RemoteEntry{}), | |
| 142 | + | InsecureTLS: resolveInsecureForTarget("", remote.RemoteEntry{}), | |
| 143 | + | }, true, nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | remotes, err := remote.LoadRemotes("") | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, false, err | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | if remotes != nil { | |
| 152 | + | if entry, found := remotes.Remotes[target]; found { | |
| 153 | + | return &remote.ResolvedRemote{ | |
| 154 | + | Name: target, | |
| 155 | + | URL: entry.URL, | |
| 156 | + | Token: resolveTokenForTarget(target, entry), | |
| 157 | + | InsecureTLS: resolveInsecureForTarget(target, entry), | |
| 158 | + | }, true, nil | |
| 225 | 159 | } | |
| 226 | 160 | } | |
| 227 | - | return filtered | |
| 161 | + | ||
| 162 | + | return nil, false, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func resolveExplicitTargetName() (string, bool) { | |
| 166 | + | if target := strings.TrimSpace(remoteFlag); target != "" { | |
| 167 | + | return target, true | |
| 168 | + | } | |
| 169 | + | if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" { | |
| 170 | + | return target, true | |
| 171 | + | } | |
| 172 | + | return "", false | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func resolveTokenForTarget(name string, entry remote.RemoteEntry) string { | |
| 176 | + | if token := strings.TrimSpace(tokenFlag); token != "" { | |
| 177 | + | return token | |
| 178 | + | } | |
| 179 | + | if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" { | |
| 180 | + | return token | |
| 181 | + | } | |
| 182 | + | if name != "" { | |
| 183 | + | return remote.ResolveTokenForRemote(name, entry) | |
| 184 | + | } | |
| 185 | + | return "" | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool { | |
| 189 | + | if insecureTLSFlag { | |
| 190 | + | return true | |
| 191 | + | } | |
| 192 | + | if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" { | |
| 193 | + | if value, err := strconv.ParseBool(env); err == nil { | |
| 194 | + | return value | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | if name != "" { | |
| 198 | + | return entry.InsecureTLS | |
| 199 | + | } | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client { | |
| 204 | + | return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // inferExplicitTarget resolves --remote/GORDON_REMOTE without probing saved remotes. | |
| 208 | + | func inferExplicitTarget(_ context.Context) (*remote.ResolvedRemote, error) { | |
| 209 | + | resolved, ok, err := resolveExplicitRemote() | |
| 210 | + | if err != nil || !ok { | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | return resolved, nil | |
| 228 | 214 | } | |
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 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
| @@ -1,58 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/spf13/cobra" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 9 | - | "github.com/bnema/gordon/internal/app" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func newRestartCmd() *cobra.Command { | |
| 13 | - | var withAttachments bool | |
| 14 | - | ||
| 15 | - | cmd := &cobra.Command{ | |
| 16 | - | Use: "restart <domain>", | |
| 17 | - | Short: "Restart a running container", | |
| 18 | - | Long: `Restarts the container for the specified route domain. | |
| 19 | - | Useful after changing environment variables with 'gordon secrets set'. | |
| 20 | - | ||
| 21 | - | Use --with-attachments to also restart attached services (databases, caches). | |
| 22 | - | ||
| 23 | - | Examples: | |
| 24 | - | gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 25 | - | gordon restart myapp.example.com --with-attachments --remote ...`, | |
| 26 | - | Args: cobra.ExactArgs(1), | |
| 27 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 28 | - | ctx := cmd.Context() | |
| 29 | - | restartDomain := args[0] | |
| 30 | - | ||
| 31 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, restartDomain) | |
| 32 | - | if err != nil { | |
| 33 | - | return err | |
| 34 | - | } | |
| 35 | - | defer handle.close() | |
| 36 | - | ||
| 37 | - | result, err := handle.plane.Restart(ctx, restartDomain, withAttachments) | |
| 38 | - | if err != nil { | |
| 39 | - | if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) { | |
| 40 | - | domain, localErr := app.SendDeploySignal(restartDomain) | |
| 41 | - | if localErr == nil { | |
| 42 | - | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |
| 43 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |
| 44 | - | return nil | |
| 45 | - | } | |
| 46 | - | return fmt.Errorf("remote restart failed: %w; local fallback failed: %v", err, localErr) | |
| 47 | - | } | |
| 48 | - | return fmt.Errorf("failed to restart: %w", err) | |
| 49 | - | } | |
| 50 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | |
| 51 | - | return nil | |
| 52 | - | }, | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)") | |
| 56 | - | ||
| 57 | - | return cmd | |
| 58 | - | } | |
M internal/adapters/in/cli/root.go +45 -207
| @@ -13,7 +13,6 @@ import ( | |||
| 13 | 13 | "github.com/spf13/cobra" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 16 | - | "github.com/bnema/gordon/internal/app" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | 18 | var ( | |
| @@ -50,16 +49,16 @@ func NewRootCmd() *cobra.Command { | |||
| 50 | 49 | tokenFlag = token | |
| 51 | 50 | return nil | |
| 52 | 51 | }, | |
| 53 | - | Short: "Gordon - A lightweight container deployment platform", | |
| 54 | - | Long: `Gordon is a self-contained container deployment platform that combines | |
| 55 | - | a Docker registry with automatic container deployment capabilities. | |
| 52 | + | Short: "Gordon - A self-hosted container deployment platform", | |
| 53 | + | Long: `Gordon is a self-hosted container deployment platform: a private | |
| 54 | + | container registry, an app runtime, and a reverse proxy. | |
| 56 | 55 | ||
| 57 | - | It listens for image pushes and automatically deploys containers based on | |
| 58 | - | configuration rules, making it ideal for single-server deployments. | |
| 56 | + | Applications are declared in standalone TOML files and managed with | |
| 57 | + | gordon apps; push transfers OCI content only and never deploys. | |
| 59 | 58 | ||
| 60 | 59 | Commands are organized by where they run: | |
| 61 | - | Server-only: Run on the machine hosting Gordon (serve, auth, reload) | |
| 62 | - | Management: Work locally or remotely via --remote flag (routes, secrets, etc.) | |
| 60 | + | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | |
| 61 | + | Management: Work locally or remotely via --remote flag (apps, secrets, etc.) | |
| 63 | 62 | Client-only: CLI utilities that don't require a running Gordon server`, | |
| 64 | 63 | } | |
| 65 | 64 | ||
| @@ -88,35 +87,14 @@ Commands are organized by where they run: | |||
| 88 | 87 | caCmd.GroupID = groupServer | |
| 89 | 88 | rootCmd.AddCommand(caCmd) | |
| 90 | 89 | ||
| 91 | - | // Management commands (work locally or via --remote) | |
| 92 | - | routesCmd := newRoutesCmd() | |
| 93 | - | routesCmd.GroupID = groupManage | |
| 94 | - | rootCmd.AddCommand(routesCmd) | |
| 95 | - | ||
| 96 | - | attachmentsCmd := newAttachmentsCmd() | |
| 97 | - | attachmentsCmd.GroupID = groupManage | |
| 98 | - | rootCmd.AddCommand(attachmentsCmd) | |
| 99 | - | ||
| 100 | 90 | secretsCmd := newSecretsCmd() | |
| 101 | 91 | secretsCmd.GroupID = groupManage | |
| 102 | 92 | rootCmd.AddCommand(secretsCmd) | |
| 103 | 93 | ||
| 104 | - | deployCmd := newDeployCmd() | |
| 105 | - | deployCmd.GroupID = groupManage | |
| 106 | - | rootCmd.AddCommand(deployCmd) | |
| 107 | - | ||
| 108 | - | restartCmd := newRestartCmd() | |
| 109 | - | restartCmd.GroupID = groupManage | |
| 110 | - | rootCmd.AddCommand(restartCmd) | |
| 111 | - | ||
| 112 | 94 | pushCmd := newPushCmd() | |
| 113 | 95 | pushCmd.GroupID = groupManage | |
| 114 | 96 | rootCmd.AddCommand(pushCmd) | |
| 115 | 97 | ||
| 116 | - | pinCmd := newPinCmd() | |
| 117 | - | pinCmd.GroupID = groupManage | |
| 118 | - | rootCmd.AddCommand(pinCmd) | |
| 119 | - | ||
| 120 | 98 | reloadCmd := newReloadCmd() | |
| 121 | 99 | reloadCmd.GroupID = groupManage | |
| 122 | 100 | rootCmd.AddCommand(reloadCmd) | |
| @@ -137,10 +115,6 @@ Commands are organized by where they run: | |||
| 137 | 115 | imagesCmd.GroupID = groupManage | |
| 138 | 116 | rootCmd.AddCommand(imagesCmd) | |
| 139 | 117 | ||
| 140 | - | bootstrapCmd := newBootstrapCmd() | |
| 141 | - | bootstrapCmd.GroupID = groupManage | |
| 142 | - | rootCmd.AddCommand(bootstrapCmd) | |
| 143 | - | ||
| 144 | 118 | configCmd := newConfigCmd() | |
| 145 | 119 | configCmd.GroupID = groupManage | |
| 146 | 120 | rootCmd.AddCommand(configCmd) | |
| @@ -153,14 +127,6 @@ Commands are organized by where they run: | |||
| 153 | 127 | volumesCmd.GroupID = groupManage | |
| 154 | 128 | rootCmd.AddCommand(volumesCmd) | |
| 155 | 129 | ||
| 156 | - | autorouteCmd := newAutorouteCmd() | |
| 157 | - | autorouteCmd.GroupID = groupManage | |
| 158 | - | rootCmd.AddCommand(autorouteCmd) | |
| 159 | - | ||
| 160 | - | previewCmd := newPreviewCmd() | |
| 161 | - | previewCmd.GroupID = groupManage | |
| 162 | - | rootCmd.AddCommand(previewCmd) | |
| 163 | - | ||
| 164 | 130 | tlsCmd := newTLSCmd() | |
| 165 | 131 | tlsCmd.GroupID = groupManage | |
| 166 | 132 | rootCmd.AddCommand(tlsCmd) | |
| @@ -169,6 +135,10 @@ Commands are organized by where they run: | |||
| 169 | 135 | trafficCmd.GroupID = groupManage | |
| 170 | 136 | rootCmd.AddCommand(trafficCmd) | |
| 171 | 137 | ||
| 138 | + | appsCmd := newAppsCmd() | |
| 139 | + | appsCmd.GroupID = groupManage | |
| 140 | + | rootCmd.AddCommand(appsCmd) | |
| 141 | + | ||
| 172 | 142 | // Client-only commands (no server needed) | |
| 173 | 143 | remotesCmd := newRemotesCmd() | |
| 174 | 144 | remotesCmd.GroupID = groupClient | |
| @@ -222,21 +192,23 @@ func IsRemoteMode() bool { | |||
| 222 | 192 | func newReloadCmd() *cobra.Command { | |
| 223 | 193 | return &cobra.Command{ | |
| 224 | 194 | Use: "reload", | |
| 225 | - | Short: "Start containers for configured routes", | |
| 226 | - | Long: `Starts containers for routes defined in config.toml that don't have | |
| 227 | - | a running container. Running containers are never restarted to ensure 100% uptime. | |
| 195 | + | Short: "Reload installation configuration (never activates app state)", | |
| 196 | + | Long: `Reloads installation-only settings (entrypoints, TLS, limits, | |
| 197 | + | external routes) after editing gordon.toml. | |
| 228 | 198 | ||
| 229 | - | Use this command after editing config.toml to add new routes, or after pushing | |
| 230 | - | images to the registry when the route was not yet configured.`, | |
| 199 | + | Reload never activates pending app desired state, never re-resolves | |
| 200 | + | image tags, and never starts app workloads. Apps are managed with | |
| 201 | + | gordon apps (apply, deploy, lifecycle).`, | |
| 231 | 202 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 232 | - | client, isRemote, err := GetRemoteClient() | |
| 203 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 233 | 204 | if err != nil { | |
| 234 | 205 | return err | |
| 235 | 206 | } | |
| 236 | - | if isRemote { | |
| 237 | - | return runReloadRemote(cmd.Context(), client) | |
| 207 | + | defer handle.close() | |
| 208 | + | if err := handle.plane.Reload(cmd.Context()); err != nil { | |
| 209 | + | return fmt.Errorf("failed to reload: %w", err) | |
| 238 | 210 | } | |
| 239 | - | return runReload() | |
| 211 | + | return cliWriteLine(cmd.OutOrStdout(), cliRenderSuccess("Configuration reloaded successfully")) | |
| 240 | 212 | }, | |
| 241 | 213 | } | |
| 242 | 214 | } | |
| @@ -262,34 +234,9 @@ func newVersionCmd() *cobra.Command { | |||
| 262 | 234 | } | |
| 263 | 235 | } | |
| 264 | 236 | ||
| 265 | - | // runReload sends SIGUSR1 to the running Gordon process. | |
| 266 | - | func runReload() error { | |
| 267 | - | return app.SendReloadSignal() | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // runReloadRemote triggers a reload on a remote Gordon instance. | |
| 271 | - | func runReloadRemote(ctx context.Context, client *remote.Client) error { | |
| 272 | - | if err := client.Reload(ctx); err != nil { | |
| 273 | - | if shouldFallbackToLocal(err) { | |
| 274 | - | localErr := runReload() | |
| 275 | - | if localErr == nil { | |
| 276 | - | if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { | |
| 277 | - | return writeErr | |
| 278 | - | } | |
| 279 | - | if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { | |
| 280 | - | return writeErr | |
| 281 | - | } | |
| 282 | - | return nil | |
| 283 | - | } | |
| 284 | - | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | |
| 285 | - | } | |
| 286 | - | return fmt.Errorf("failed to reload: %w", err) | |
| 287 | - | } | |
| 288 | - | if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { | |
| 289 | - | return err | |
| 290 | - | } | |
| 291 | - | return nil | |
| 292 | - | } | |
| 237 | + | // cliConfigPath for local operations. If empty, config is auto-discovered | |
| 238 | + | // from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml). | |
| 239 | + | var cliConfigPath string | |
| 293 | 240 | ||
| 294 | 241 | // newLogsCmd creates the logs command. | |
| 295 | 242 | func newLogsCmd() *cobra.Command { | |
| @@ -298,29 +245,23 @@ func newLogsCmd() *cobra.Command { | |||
| 298 | 245 | var logsConfigPath string | |
| 299 | 246 | ||
| 300 | 247 | cmd := &cobra.Command{ | |
| 301 | - | Use: "logs [domain]", | |
| 302 | - | Short: "Show logs (Gordon process or container)", | |
| 303 | - | Long: `Shows logs from the Gordon process or a specific container. | |
| 248 | + | Use: "logs", | |
| 249 | + | Short: "Show Gordon process logs", | |
| 250 | + | Long: `Shows logs from the Gordon daemon process. | |
| 304 | 251 | ||
| 305 | - | Without a domain argument, shows Gordon process logs. | |
| 306 | - | With a domain argument, shows container logs for that domain. | |
| 252 | + | Application workload output is read with gordon apps logs APP --service SVC, | |
| 253 | + | which resolves the app's active container through the daemon. | |
| 307 | 254 | ||
| 308 | 255 | Examples: | |
| 309 | - | gordon logs # Gordon process logs | |
| 310 | - | gordon logs -f # Follow process logs | |
| 311 | - | gordon logs myapp.local # Container logs for myapp.local | |
| 312 | - | gordon logs myapp.local -f # Follow container logs | |
| 256 | + | gordon logs # Gordon process logs | |
| 257 | + | gordon logs -f # Follow process logs | |
| 258 | + | gordon logs -n 100 # Last 100 lines | |
| 313 | 259 | ||
| 314 | 260 | Remote mode: | |
| 315 | - | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |
| 316 | - | gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 317 | - | Args: cobra.MaximumNArgs(1), | |
| 261 | + | gordon logs --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 262 | + | Args: cobra.NoArgs, | |
| 318 | 263 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 319 | - | logDomain := "" | |
| 320 | - | if len(args) > 0 { | |
| 321 | - | logDomain = args[0] | |
| 322 | - | } | |
| 323 | - | return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout()) | |
| 264 | + | return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout()) | |
| 324 | 265 | }, | |
| 325 | 266 | } | |
| 326 | 267 | ||
| @@ -332,101 +273,23 @@ Remote mode: | |||
| 332 | 273 | } | |
| 333 | 274 | ||
| 334 | 275 | // runLogs handles the logs command logic. | |
| 335 | - | func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error { | |
| 336 | - | if logDomain != "" { | |
| 337 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain) | |
| 338 | - | if err != nil { | |
| 339 | - | return err | |
| 340 | - | } | |
| 341 | - | defer handle.close() | |
| 342 | - | return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out) | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | client, isRemote, err := GetRemoteClient() | |
| 276 | + | func runLogs(ctx context.Context, logsConfigPath string, follow bool, lines int, out io.Writer) error { | |
| 277 | + | handle, err := resolveControlPlane(logsConfigPath) | |
| 346 | 278 | if err != nil { | |
| 347 | 279 | return err | |
| 348 | 280 | } | |
| 349 | - | if isRemote { | |
| 350 | - | return runLogsRemote(ctx, client, logDomain, follow, lines, out) | |
| 351 | - | } | |
| 352 | - | return runLogsLocal(logsConfigPath, logDomain, follow, lines, out) | |
| 281 | + | defer handle.close() | |
| 282 | + | return runProcessLogs(ctx, handle.plane, follow, lines, out) | |
| 353 | 283 | } | |
| 354 | 284 | ||
| 355 | - | // runLogsRemote fetches logs from a remote Gordon instance. | |
| 356 | - | func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error { | |
| 285 | + | func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int, out io.Writer) error { | |
| 357 | 286 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 358 | 287 | defer cancel() | |
| 359 | 288 | ||
| 360 | 289 | if follow { | |
| 361 | - | return streamLogsRemote(ctx, client, logDomain, lines, out) | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | if logDomain == "" { | |
| 365 | - | // Process logs | |
| 366 | - | logLines, err := client.GetProcessLogs(ctx, lines) | |
| 367 | - | if err != nil { | |
| 368 | - | return fmt.Errorf("failed to get process logs: %w", err) | |
| 369 | - | } | |
| 370 | - | for _, line := range logLines { | |
| 371 | - | if err := cliWriteLine(out, line); err != nil { | |
| 372 | - | return err | |
| 373 | - | } | |
| 374 | - | } | |
| 375 | - | } else { | |
| 376 | - | // Container logs | |
| 377 | - | logLines, err := client.GetContainerLogs(ctx, logDomain, lines) | |
| 290 | + | ch, err := cp.StreamProcessLogs(ctx, lines) | |
| 378 | 291 | if err != nil { | |
| 379 | - | return fmt.Errorf("failed to get container logs: %w", err) | |
| 380 | - | } | |
| 381 | - | for _, line := range logLines { | |
| 382 | - | if err := cliWriteLine(out, line); err != nil { | |
| 383 | - | return err | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | return nil | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | // streamLogsRemote streams logs from a remote Gordon instance. | |
| 391 | - | func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error { | |
| 392 | - | var ch <-chan string | |
| 393 | - | var err error | |
| 394 | - | ||
| 395 | - | if logDomain == "" { | |
| 396 | - | ch, err = client.StreamProcessLogs(ctx, lines) | |
| 397 | - | } else { | |
| 398 | - | ch, err = client.StreamContainerLogs(ctx, logDomain, lines) | |
| 399 | - | } | |
| 400 | - | if err != nil { | |
| 401 | - | return fmt.Errorf("failed to stream logs: %w", err) | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | for line := range ch { | |
| 405 | - | if err := cliWriteLine(out, line); err != nil { | |
| 406 | - | return err | |
| 407 | - | } | |
| 408 | - | } | |
| 409 | - | return nil | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | // runLogsLocal shows logs from a local Gordon instance. | |
| 413 | - | func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error { | |
| 414 | - | if logDomain == "" { | |
| 415 | - | // Process logs - use existing app.ShowLogs | |
| 416 | - | return app.ShowLogs(logsConfigPath, follow, lines) | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines) | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error { | |
| 423 | - | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 424 | - | defer cancel() | |
| 425 | - | ||
| 426 | - | if follow { | |
| 427 | - | ch, err := cp.StreamContainerLogs(ctx, logDomain, lines) | |
| 428 | - | if err != nil { | |
| 429 | - | return fmt.Errorf("failed to stream container logs: %w", err) | |
| 292 | + | return fmt.Errorf("failed to stream process logs: %w", err) | |
| 430 | 293 | } | |
| 431 | 294 | for line := range ch { | |
| 432 | 295 | if err := cliWriteLine(out, line); err != nil { | |
| @@ -436,9 +299,9 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo | |||
| 436 | 299 | return nil | |
| 437 | 300 | } | |
| 438 | 301 | ||
| 439 | - | logLines, err := cp.GetContainerLogs(ctx, logDomain, lines) | |
| 302 | + | logLines, err := cp.GetProcessLogs(ctx, lines) | |
| 440 | 303 | if err != nil { | |
| 441 | - | return fmt.Errorf("failed to get container logs: %w", err) | |
| 304 | + | return fmt.Errorf("failed to get process logs: %w", err) | |
| 442 | 305 | } | |
| 443 | 306 | for _, line := range logLines { | |
| 444 | 307 | if err := cliWriteLine(out, line); err != nil { | |
| @@ -448,31 +311,6 @@ func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, fo | |||
| 448 | 311 | return nil | |
| 449 | 312 | } | |
| 450 | 313 | ||
| 451 | - | // showContainerLogsLocal is retained for UI adoption coverage and fallback messaging. | |
| 452 | - | func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error { | |
| 453 | - | if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | |
| 454 | - | return err | |
| 455 | - | } | |
| 456 | - | if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil { | |
| 457 | - | return err | |
| 458 | - | } | |
| 459 | - | if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil { | |
| 460 | - | return err | |
| 461 | - | } | |
| 462 | - | if follow { | |
| 463 | - | if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { | |
| 464 | - | return err | |
| 465 | - | } | |
| 466 | - | } | |
| 467 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 468 | - | return err | |
| 469 | - | } | |
| 470 | - | if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { | |
| 471 | - | return err | |
| 472 | - | } | |
| 473 | - | return nil | |
| 474 | - | } | |
| 475 | - | ||
| 476 | 314 | // SetVersionInfo sets the version information for the CLI. | |
| 477 | 315 | func SetVersionInfo(version, commit, date string) { | |
| 478 | 316 | Version = version | |
D internal/adapters/in/cli/routes.go +0 -1837 collapsed to keep the page light
D internal/adapters/in/cli/routes_test.go +0 -1209 collapsed to keep the page light
M internal/adapters/in/cli/secrets.go +37 -154
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 9 | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 10 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 11 | 10 | ||
| @@ -16,11 +15,12 @@ import ( | |||
| 16 | 15 | func newSecretsCmd() *cobra.Command { | |
| 17 | 16 | cmd := &cobra.Command{ | |
| 18 | 17 | Use: "secrets", | |
| 19 | - | Short: "Manage secrets", | |
| 20 | - | Long: `Manage secrets (environment variables) for routes and attachments. | |
| 18 | + | Short: "Manage installation secrets", | |
| 19 | + | Long: `Manage installation secrets (per-host entries such as service auth). | |
| 21 | 20 | ||
| 22 | - | Secrets are stored per-domain and injected into containers as environment variables. | |
| 23 | - | Use --attachment to target attachment containers (databases, caches, etc.). | |
| 21 | + | Application secret VALUES are managed with gordon apps secrets and stay | |
| 22 | + | in pass under gordon/apps/<uuid>/<service>/<name>. The commands below | |
| 23 | + | manage the installation secret store only. | |
| 24 | 24 | ||
| 25 | 25 | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |
| 26 | 26 | these commands operate on the remote server.`, | |
| @@ -43,7 +43,6 @@ func newSecretsListCmd() *cobra.Command { | |||
| 43 | 43 | Long: `List all secret keys configured for a domain. | |
| 44 | 44 | ||
| 45 | 45 | Note: Only secret keys are shown, not values (for security). | |
| 46 | - | Attachment secrets (for services like databases) are also displayed. | |
| 47 | 46 | ||
| 48 | 47 | Examples: | |
| 49 | 48 | gordon secrets list app.mydomain.com | |
| @@ -64,28 +63,22 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 64 | 63 | ctx := cmd.Context() | |
| 65 | 64 | secretDomain := args[0] | |
| 66 | 65 | ||
| 67 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 66 | + | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 68 | 67 | if err != nil { | |
| 69 | 68 | return err | |
| 70 | 69 | } | |
| 71 | 70 | defer handle.close() | |
| 72 | 71 | ||
| 73 | - | keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain) | |
| 72 | + | keys, err := fetchSecrets(ctx, handle.plane, secretDomain) | |
| 74 | 73 | if err != nil { | |
| 75 | 74 | return err | |
| 76 | 75 | } | |
| 77 | 76 | ||
| 78 | - | totalSecrets := len(keys) | |
| 79 | - | for _, att := range attachments { | |
| 80 | - | totalSecrets += len(att.Keys) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | if totalSecrets == 0 { | |
| 77 | + | if len(keys) == 0 { | |
| 84 | 78 | if jsonOut { | |
| 85 | 79 | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 86 | - | "domain": secretDomain, | |
| 87 | - | "keys": []string{}, | |
| 88 | - | "attachments": []any{}, | |
| 80 | + | "domain": secretDomain, | |
| 81 | + | "keys": []string{}, | |
| 89 | 82 | }) | |
| 90 | 83 | } | |
| 91 | 84 | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | |
| @@ -93,13 +86,9 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 93 | 86 | } | |
| 94 | 87 | ||
| 95 | 88 | if jsonOut { | |
| 96 | - | if attachments == nil { | |
| 97 | - | attachments = []remote.AttachmentSecrets{} | |
| 98 | - | } | |
| 99 | 89 | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 100 | - | "domain": secretDomain, | |
| 101 | - | "keys": keys, | |
| 102 | - | "attachments": attachments, | |
| 90 | + | "domain": secretDomain, | |
| 91 | + | "keys": keys, | |
| 103 | 92 | }) | |
| 104 | 93 | } | |
| 105 | 94 | ||
| @@ -110,7 +99,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 110 | 99 | fmt.Println(styles.Theme.Title.Render(title)) | |
| 111 | 100 | fmt.Println() | |
| 112 | 101 | ||
| 113 | - | rows := buildSecretsTableRows(keys, attachments) | |
| 102 | + | rows := buildSecretsTableRows(keys) | |
| 114 | 103 | ||
| 115 | 104 | table := components.NewTable( | |
| 116 | 105 | components.WithColumns([]components.TableColumn{ | |
| @@ -124,120 +113,39 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 124 | 113 | return nil | |
| 125 | 114 | } | |
| 126 | 115 | ||
| 127 | - | // fetchSecretsWithAttachments retrieves secrets from the selected control plane. | |
| 128 | - | func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) { | |
| 129 | - | result, err := cp.ListSecretsWithAttachments(ctx, secretDomain) | |
| 116 | + | // fetchSecrets retrieves secret keys from the selected control plane. | |
| 117 | + | func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) { | |
| 118 | + | result, err := cp.ListSecrets(ctx, secretDomain) | |
| 130 | 119 | if err != nil { | |
| 131 | - | return nil, nil, fmt.Errorf("failed to list secrets: %w", err) | |
| 120 | + | return nil, fmt.Errorf("failed to list secrets: %w", err) | |
| 132 | 121 | } | |
| 133 | - | return result.Keys, result.Attachments, nil | |
| 122 | + | return result.Keys, nil | |
| 134 | 123 | } | |
| 135 | 124 | ||
| 136 | - | // buildSecretsTableRows builds table rows with tree structure for attachments. | |
| 137 | - | func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string { | |
| 125 | + | // buildSecretsTableRows builds table rows for secret keys. | |
| 126 | + | func buildSecretsTableRows(keys []string) [][]string { | |
| 138 | 127 | var rows [][]string | |
| 139 | - | ||
| 140 | - | // Domain secrets first | |
| 141 | 128 | for _, key := range keys { | |
| 142 | 129 | rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")}) | |
| 143 | 130 | } | |
| 144 | - | ||
| 145 | - | // Attachment secrets with tree structure | |
| 146 | - | for i, att := range attachments { | |
| 147 | - | isLastAttachment := i == len(attachments)-1 | |
| 148 | - | rows = append(rows, buildAttachmentRows(att, isLastAttachment)...) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | return rows | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // buildAttachmentRows builds table rows for a single attachment with tree structure. | |
| 155 | - | func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string { | |
| 156 | - | var rows [][]string | |
| 157 | - | ||
| 158 | - | // Attachment header with tree prefix | |
| 159 | - | prefix := styles.IconTreeBranch + styles.IconTreeLine | |
| 160 | - | if isLastAttachment { | |
| 161 | - | prefix = styles.IconTreeLast + styles.IconTreeLine | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | serviceName := extractServiceName(att.Service) | |
| 165 | - | attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName))) | |
| 166 | - | rows = append(rows, []string{attachmentHeader, ""}) | |
| 167 | - | ||
| 168 | - | // Keys for this attachment with nested tree structure | |
| 169 | - | for j, key := range att.Keys { | |
| 170 | - | isLastKey := j == len(att.Keys)-1 | |
| 171 | - | keyPrefix := getKeyPrefix(isLastAttachment, isLastKey) | |
| 172 | - | rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")}) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | 131 | return rows | |
| 176 | 132 | } | |
| 177 | 133 | ||
| 178 | - | // extractServiceName extracts a short service name from a container name. | |
| 179 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres" | |
| 180 | - | func extractServiceName(containerName string) string { | |
| 181 | - | if !strings.HasPrefix(containerName, "gordon-") { | |
| 182 | - | return containerName | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | parts := strings.SplitN(containerName, "-", 2) | |
| 186 | - | if len(parts) <= 1 { | |
| 187 | - | return containerName | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | serviceName := parts[1] | |
| 191 | - | allParts := strings.Split(serviceName, "-") | |
| 192 | - | ||
| 193 | - | // Handle service names based on the number of segments explicitly | |
| 194 | - | if len(allParts) < 2 { | |
| 195 | - | // No additional segments; use the service name as-is. | |
| 196 | - | return serviceName | |
| 197 | - | } | |
| 198 | - | if len(allParts) == 2 { | |
| 199 | - | // Exactly two segments; the service name is already in the desired form. | |
| 200 | - | return strings.Join(allParts, "-") | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | // More than two segments: take the last two as the short service name (e.g., "gitea-postgres"). | |
| 204 | - | return strings.Join(allParts[len(allParts)-2:], "-") | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | // getKeyPrefix returns the tree prefix for a key based on its position. | |
| 208 | - | func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |
| 209 | - | if isLastAttachment { | |
| 210 | - | // Parent is last, use space continuation | |
| 211 | - | if isLastKey { | |
| 212 | - | return " " + styles.IconTreeLast + styles.IconTreeLine | |
| 213 | - | } | |
| 214 | - | return " " + styles.IconTreeBranch + styles.IconTreeLine | |
| 215 | - | } | |
| 216 | - | // Parent has siblings, use vertical line continuation | |
| 217 | - | if isLastKey { | |
| 218 | - | return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine | |
| 219 | - | } | |
| 220 | - | return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine | |
| 221 | - | } | |
| 222 | - | ||
| 223 | 134 | // newSecretsSetCmd creates the secrets set command. | |
| 224 | 135 | func newSecretsSetCmd() *cobra.Command { | |
| 225 | - | var attachment, fromFile string | |
| 136 | + | var fromFile string | |
| 226 | 137 | var jsonOut bool | |
| 227 | 138 | ||
| 228 | 139 | cmd := &cobra.Command{ | |
| 229 | 140 | Use: "set <domain> --from-file <path>", | |
| 230 | - | Short: "Set secrets for a domain or attachment", | |
| 231 | - | Long: `Set one or more secrets for a domain or an attachment container. | |
| 141 | + | Short: "Set installation secrets for a domain", | |
| 142 | + | Long: `Set one or more installation secrets for a domain. | |
| 232 | 143 | ||
| 233 | 144 | Secrets are read from a mode 0600 file containing one KEY=value pair per line. | |
| 234 | - | ||
| 235 | - | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 236 | - | of the main domain container. | |
| 145 | + | For app secret values, use gordon apps secrets instead. | |
| 237 | 146 | ||
| 238 | 147 | Examples: | |
| 239 | - | gordon secrets set app.mydomain.com --from-file ./app.env | |
| 240 | - | gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, | |
| 148 | + | gordon secrets set app.mydomain.com --from-file ./app.env`, | |
| 241 | 149 | Args: cobra.ExactArgs(1), | |
| 242 | 150 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 243 | 151 | ctx := cmd.Context() | |
| @@ -256,29 +164,20 @@ Examples: | |||
| 256 | 164 | secrets[parts[0]] = parts[1] | |
| 257 | 165 | } | |
| 258 | 166 | ||
| 259 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 167 | + | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 260 | 168 | if err != nil { | |
| 261 | 169 | return err | |
| 262 | 170 | } | |
| 263 | 171 | defer handle.close() | |
| 264 | - | if attachment != "" { | |
| 265 | - | if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |
| 266 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 267 | - | } | |
| 268 | - | } else { | |
| 269 | - | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 270 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 271 | - | } | |
| 172 | + | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 173 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 272 | 174 | } | |
| 273 | 175 | ||
| 274 | 176 | target := secretDomain | |
| 275 | - | if attachment != "" { | |
| 276 | - | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 277 | - | } | |
| 278 | 177 | ||
| 279 | 178 | if jsonOut { | |
| 280 | 179 | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 281 | - | "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, | |
| 180 | + | "domain": secretDomain, "count": len(secrets), "updated": true, | |
| 282 | 181 | }) | |
| 283 | 182 | } | |
| 284 | 183 | if len(secrets) == 1 { | |
| @@ -290,7 +189,6 @@ Examples: | |||
| 290 | 189 | }, | |
| 291 | 190 | } | |
| 292 | 191 | ||
| 293 | - | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 294 | 192 | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |
| 295 | 193 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 296 | 194 | _ = cmd.MarkFlagRequired("from-file") | |
| @@ -300,23 +198,18 @@ Examples: | |||
| 300 | 198 | ||
| 301 | 199 | // newSecretsRemoveCmd creates the secrets remove command. | |
| 302 | 200 | func newSecretsRemoveCmd() *cobra.Command { | |
| 303 | - | var ( | |
| 304 | - | force bool | |
| 305 | - | attachment string | |
| 306 | - | ) | |
| 201 | + | var force bool | |
| 307 | 202 | ||
| 308 | 203 | cmd := &cobra.Command{ | |
| 309 | 204 | Use: "remove <domain> <key>", | |
| 310 | - | Short: "Remove a secret", | |
| 311 | - | Long: `Remove a secret from a domain or an attachment container. | |
| 205 | + | Short: "Remove an installation secret", | |
| 206 | + | Long: `Remove an installation secret from a domain. | |
| 312 | 207 | ||
| 313 | - | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 314 | - | of the main domain container. | |
| 208 | + | For app secret values, use gordon apps secrets delete instead. | |
| 315 | 209 | ||
| 316 | 210 | Examples: | |
| 317 | 211 | gordon secrets remove app.mydomain.com OLD_API_KEY | |
| 318 | - | gordon secrets remove app.mydomain.com OLD_API_KEY --force | |
| 319 | - | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |
| 212 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | |
| 320 | 213 | Args: cobra.ExactArgs(2), | |
| 321 | 214 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 322 | 215 | ctx := cmd.Context() | |
| @@ -324,9 +217,6 @@ Examples: | |||
| 324 | 217 | key := args[1] | |
| 325 | 218 | ||
| 326 | 219 | target := secretDomain | |
| 327 | - | if attachment != "" { | |
| 328 | - | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 329 | - | } | |
| 330 | 220 | ||
| 331 | 221 | // Confirm unless --force | |
| 332 | 222 | if !force { | |
| @@ -342,19 +232,13 @@ Examples: | |||
| 342 | 232 | } | |
| 343 | 233 | } | |
| 344 | 234 | ||
| 345 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 235 | + | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 346 | 236 | if err != nil { | |
| 347 | 237 | return err | |
| 348 | 238 | } | |
| 349 | 239 | defer handle.close() | |
| 350 | - | if attachment != "" { | |
| 351 | - | if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |
| 352 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 353 | - | } | |
| 354 | - | } else { | |
| 355 | - | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 356 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 357 | - | } | |
| 240 | + | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 241 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 358 | 242 | } | |
| 359 | 243 | ||
| 360 | 244 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) | |
| @@ -363,7 +247,6 @@ Examples: | |||
| 363 | 247 | } | |
| 364 | 248 | ||
| 365 | 249 | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |
| 366 | - | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 367 | 250 | ||
| 368 | 251 | return cmd | |
| 369 | 252 | } | |
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
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "sort" | |
| 8 | + | ||
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // newStatusCmd creates the status command. | |
| 16 | + | func newStatusCmd() *cobra.Command { | |
| 17 | + | return &cobra.Command{ | |
| 18 | + | Use: "status", | |
| 19 | + | Short: "Show Gordon server status", | |
| 20 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 21 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 22 | + | if err != nil { | |
| 23 | + | return err | |
| 24 | + | } | |
| 25 | + | defer handle.close() | |
| 26 | + | ||
| 27 | + | return runStatusCmd(cmd.Context(), handle.plane, cmd.OutOrStdout()) | |
| 28 | + | }, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func runStatusCmd(ctx context.Context, plane ControlPlane, out io.Writer) error { | |
| 33 | + | status, err := plane.GetStatus(ctx) | |
| 34 | + | if err != nil { | |
| 35 | + | return fmt.Errorf("failed to get status: %w", err) | |
| 36 | + | } | |
| 37 | + | if status == nil { | |
| 38 | + | return fmt.Errorf("failed to get status: empty response") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if err := cliWriteLine(out, styles.Theme.Title.Render("Gordon Status")); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if err := cliWriteLine(out, fmt.Sprintf("%s %s", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)); err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)); err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | if err := cliWriteLine(out, fmt.Sprintf("%s %d", styles.Theme.Bold.Render("Apps:"), status.Apps)); err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | if err := cliWriteLine(out, fmt.Sprintf("%s %v", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | return renderStatusFleet(out, status.ContainerStatus) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func renderStatusFleet(out io.Writer, fleet map[string]string) error { | |
| 68 | + | if len(fleet) == 0 { | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if err := cliWriteLine(out, styles.Theme.Bold.Render("Container Status:")); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | names := make([]string, 0, len(fleet)) | |
| 78 | + | for name := range fleet { | |
| 79 | + | names = append(names, name) | |
| 80 | + | } | |
| 81 | + | sort.Strings(names) | |
| 82 | + | for _, name := range names { | |
| 83 | + | badge := components.ContainerStatusBadge(fleet[name]) | |
| 84 | + | if err := cliWriteLine(out, fmt.Sprintf(" %s: %s", name, badge)); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return nil | |
| 89 | + | } | |
M internal/adapters/in/cli/tls.go +1 -1
| @@ -33,7 +33,7 @@ func newTLSStatusCmd() *cobra.Command { | |||
| 33 | 33 | Use: "status", | |
| 34 | 34 | Short: "Show public TLS certificate status", | |
| 35 | 35 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 36 | - | handle, err := tlsResolveControlPlane(configPath) | |
| 36 | + | handle, err := tlsResolveControlPlane(cliConfigPath) | |
| 37 | 37 | if err != nil { | |
| 38 | 38 | return err | |
| 39 | 39 | } | |
M internal/adapters/in/cli/traffic_status.go +1 -1
| @@ -20,7 +20,7 @@ func newTrafficStatusCmd() *cobra.Command { | |||
| 20 | 20 | Short: "Show traffic entrypoint, router, and counter status", | |
| 21 | 21 | Args: cobra.NoArgs, | |
| 22 | 22 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 23 | - | handle, err := trafficResolveControlPlane(configPath) | |
| 23 | + | handle, err := trafficResolveControlPlane(cliConfigPath) | |
| 24 | 24 | if err != nil { | |
| 25 | 25 | return err | |
| 26 | 26 | } | |
M internal/adapters/in/cli/traffic_test.go +3 -24
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | 7 | "net/http" | |
| 9 | 8 | "net/http/httptest" | |
| 10 | 9 | "testing" | |
| @@ -13,6 +12,7 @@ import ( | |||
| 13 | 12 | "github.com/stretchr/testify/require" | |
| 14 | 13 | ||
| 15 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 16 | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 18 | ) | |
| @@ -81,12 +81,12 @@ func TestTrafficStatusJSONOutput(t *testing.T) { | |||
| 81 | 81 | ||
| 82 | 82 | func TestRunTrafficStatusUsesControlPlane(t *testing.T) { | |
| 83 | 83 | status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"} | |
| 84 | - | cp := &trafficStatusPlane{status: status} | |
| 84 | + | cp := mocks.NewMockControlPlane(t) | |
| 85 | + | cp.EXPECT().GetTrafficStatus(context.Background()).Return(status, nil).Once() | |
| 85 | 86 | ||
| 86 | 87 | var buf bytes.Buffer | |
| 87 | 88 | require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true)) | |
| 88 | 89 | ||
| 89 | - | assert.True(t, cp.called) | |
| 90 | 90 | assert.Contains(t, buf.String(), `"last_reload_status": "ok"`) | |
| 91 | 91 | } | |
| 92 | 92 | ||
| @@ -109,24 +109,3 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |||
| 109 | 109 | assert.Contains(t, output, "ok") | |
| 110 | 110 | assert.Contains(t, output, "udp=3") | |
| 111 | 111 | } | |
| 112 | - | ||
| 113 | - | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | |
| 114 | - | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | |
| 115 | - | require.Error(t, err) | |
| 116 | - | assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable)) | |
| 117 | - | assert.Contains(t, err.Error(), "in-process CLI control plane") | |
| 118 | - | assert.Contains(t, err.Error(), "running Gordon daemon") | |
| 119 | - | assert.Contains(t, err.Error(), "--remote") | |
| 120 | - | assert.Contains(t, err.Error(), "GORDON_REMOTE") | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | type trafficStatusPlane struct { | |
| 124 | - | ControlPlane | |
| 125 | - | status *dto.TrafficStatusResponse | |
| 126 | - | called bool | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) { | |
| 130 | - | p.called = true | |
| 131 | - | return p.status, nil | |
| 132 | - | } | |
M internal/adapters/in/cli/ui_adoption_test.go +3 -127
| @@ -3,31 +3,16 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | - | "go/ast" | |
| 7 | - | "go/parser" | |
| 8 | - | "go/token" | |
| 9 | 6 | "io" | |
| 10 | - | "strings" | |
| 11 | 7 | "sync" | |
| 12 | 8 | "testing" | |
| 13 | 9 | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 14 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | 13 | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 16 | 14 | ) | |
| 17 | 15 | ||
| 18 | - | var uiAdoptionHelperCalls = map[string]struct{}{ | |
| 19 | - | "cliWriteLine": {}, | |
| 20 | - | "cliWritef": {}, | |
| 21 | - | "cliRenderTitle": {}, | |
| 22 | - | "cliRenderMuted": {}, | |
| 23 | - | "cliRenderEmptyState": {}, | |
| 24 | - | "cliRenderListItem": {}, | |
| 25 | - | "cliRenderMeta": {}, | |
| 26 | - | "cliRenderSuccess": {}, | |
| 27 | - | "cliRenderWarning": {}, | |
| 28 | - | "cliRenderInfo": {}, | |
| 29 | - | } | |
| 30 | - | ||
| 31 | 16 | // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and | |
| 32 | 17 | // cliWritef variables. Any test that overrides these seams must hold this | |
| 33 | 18 | // mutex for the duration of the override and restore the originals on cleanup. | |
| @@ -54,102 +39,6 @@ func TestPresentationHelpers(t *testing.T) { | |||
| 54 | 39 | } | |
| 55 | 40 | } | |
| 56 | 41 | ||
| 57 | - | func TestUIAdoption(t *testing.T) { | |
| 58 | - | for i := range uiAdoptionExpectations { | |
| 59 | - | expect := uiAdoptionExpectations[i] | |
| 60 | - | t.Run(expect.family, func(t *testing.T) { | |
| 61 | - | fset := token.NewFileSet() | |
| 62 | - | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |
| 63 | - | if err != nil { | |
| 64 | - | t.Fatalf("failed to parse %s: %v", expect.file, err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | for _, fnName := range expect.functions { | |
| 68 | - | t.Run(fnName, func(t *testing.T) { | |
| 69 | - | fn := findFuncDecl(fileNode, fnName) | |
| 70 | - | if fn == nil { | |
| 71 | - | t.Fatalf("function %s not found in %s", fnName, expect.file) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | hasHelperCall := false | |
| 75 | - | hasSharedUIUsage := false | |
| 76 | - | hasForbiddenRawPrint := false | |
| 77 | - | ||
| 78 | - | ast.Inspect(fn.Body, func(n ast.Node) bool { | |
| 79 | - | call, ok := n.(*ast.CallExpr) | |
| 80 | - | if !ok { | |
| 81 | - | return true | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | switch fun := call.Fun.(type) { | |
| 85 | - | case *ast.Ident: | |
| 86 | - | if _, ok := uiAdoptionHelperCalls[fun.Name]; ok { | |
| 87 | - | hasHelperCall = true | |
| 88 | - | } | |
| 89 | - | case *ast.SelectorExpr: | |
| 90 | - | if isForbiddenRawPrintCall(call, fun) { | |
| 91 | - | hasForbiddenRawPrint = true | |
| 92 | - | } | |
| 93 | - | pkgIdent, ok := fun.X.(*ast.Ident) | |
| 94 | - | if !ok { | |
| 95 | - | return true | |
| 96 | - | } | |
| 97 | - | if pkgIdent.Name == "styles" || pkgIdent.Name == "components" { | |
| 98 | - | hasSharedUIUsage = true | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | return true | |
| 103 | - | }) | |
| 104 | - | ||
| 105 | - | if !hasHelperCall && !hasSharedUIUsage { | |
| 106 | - | t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | if hasForbiddenRawPrint { | |
| 110 | - | t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file) | |
| 111 | - | } | |
| 112 | - | }) | |
| 113 | - | } | |
| 114 | - | }) | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |
| 119 | - | pkgIdent, ok := sel.X.(*ast.Ident) | |
| 120 | - | if !ok { | |
| 121 | - | return false | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | if pkgIdent.Name == "fmt" { | |
| 125 | - | switch sel.Sel.Name { | |
| 126 | - | case "Print", "Printf", "Println": | |
| 127 | - | return true | |
| 128 | - | case "Fprint", "Fprintf", "Fprintln": | |
| 129 | - | if len(call.Args) == 0 { | |
| 130 | - | return true | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | switch dst := call.Args[0].(type) { | |
| 134 | - | case *ast.SelectorExpr: | |
| 135 | - | if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") { | |
| 136 | - | return true | |
| 137 | - | } | |
| 138 | - | case *ast.Ident: | |
| 139 | - | if dst.Name == "out" { | |
| 140 | - | return true | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") { | |
| 147 | - | return true | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | return false | |
| 151 | - | } | |
| 152 | - | ||
| 153 | 42 | func TestUIAdoptionRuntimeSeams(t *testing.T) { | |
| 154 | 43 | uiAdoptionSeamMu.Lock() | |
| 155 | 44 | defer uiAdoptionSeamMu.Unlock() | |
| @@ -190,7 +79,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 190 | 79 | } | |
| 191 | 80 | ||
| 192 | 81 | pruneMock := climocks.NewMockimagesClient(t) | |
| 193 | - | pruneMock.EXPECT().ListImages(context.Background()).Return([]dto.Image{}, nil).Once() | |
| 82 | + | pruneMock.EXPECT().PruneImages(context.Background(), mock.Anything).Return(&dto.ImagePruneResponse{Plan: dto.PruneSummary{}}, nil).Once() | |
| 194 | 83 | ||
| 195 | 84 | pruneBuf := new(bytes.Buffer) | |
| 196 | 85 | if err := runImagesPrune(context.Background(), pruneMock, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil { | |
| @@ -204,16 +93,3 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 204 | 93 | t.Fatal("expected cliWritef to be called") | |
| 205 | 94 | } | |
| 206 | 95 | } | |
| 207 | - | ||
| 208 | - | func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { | |
| 209 | - | for _, decl := range file.Decls { | |
| 210 | - | fn, ok := decl.(*ast.FuncDecl) | |
| 211 | - | if !ok { | |
| 212 | - | continue | |
| 213 | - | } | |
| 214 | - | if fn.Name.Name == name { | |
| 215 | - | return fn | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | return nil | |
| 219 | - | } | |
M internal/adapters/in/cli/volumes.go +40 -26
| @@ -40,7 +40,7 @@ func newVolumesListCmd() *cobra.Command { | |||
| 40 | 40 | Use: "list", | |
| 41 | 41 | Short: "List all volumes", | |
| 42 | 42 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 43 | - | handle, err := resolveControlPlane(configPath) | |
| 43 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 44 | 44 | if err != nil { | |
| 45 | 45 | return err | |
| 46 | 46 | } | |
| @@ -56,9 +56,14 @@ func newVolumesPruneCmd() *cobra.Command { | |||
| 56 | 56 | var opts volumesPruneOptions | |
| 57 | 57 | cmd := &cobra.Command{ | |
| 58 | 58 | Use: "prune", | |
| 59 | - | Short: "Remove orphaned volumes", | |
| 59 | + | Short: "Remove released app-owned volumes", | |
| 60 | + | Long: `Remove app-owned volumes that were explicitly released and are unused. | |
| 61 | + | ||
| 62 | + | Only a volume whose durable ownership record marks it released, whose runtime | |
| 63 | + | labels agree with that record, and which no container mounts is removed. | |
| 64 | + | Attached, retained, legacy-managed, unmanaged, and unknown volumes survive.`, | |
| 60 | 65 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 61 | - | handle, err := resolveControlPlane(configPath) | |
| 66 | + | handle, err := resolveControlPlane(cliConfigPath) | |
| 62 | 67 | if err != nil { | |
| 63 | 68 | return err | |
| 64 | 69 | } | |
| @@ -66,7 +71,7 @@ func newVolumesPruneCmd() *cobra.Command { | |||
| 66 | 71 | return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout()) | |
| 67 | 72 | }, | |
| 68 | 73 | } | |
| 69 | - | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed") | |
| 74 | + | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "report the plan without deleting") | |
| 70 | 75 | cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt") | |
| 71 | 76 | cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON") | |
| 72 | 77 | return cmd | |
| @@ -133,33 +138,27 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun | |||
| 133 | 138 | return err | |
| 134 | 139 | } | |
| 135 | 140 | ||
| 136 | - | if preview.VolumesRemoved == 0 { | |
| 137 | - | if opts.Json { | |
| 138 | - | return writeJSON(out, preview) | |
| 139 | - | } | |
| 140 | - | return cliWriteLine(out, "No orphaned volumes to remove.") | |
| 141 | - | } | |
| 142 | - | ||
| 143 | 141 | if opts.DryRun { | |
| 144 | - | if opts.Json { | |
| 145 | - | return writeJSON(out, preview) | |
| 146 | - | } | |
| 147 | - | return renderPrunePreview(out, preview) | |
| 142 | + | return renderVolumePrunePlan(out, opts.Json, preview) | |
| 143 | + | } | |
| 144 | + | if preview.Plan.Eligible == 0 { | |
| 145 | + | // A zero-eligibility plan is a normal outcome: the report explains | |
| 146 | + | // why each volume survived. | |
| 147 | + | return renderVolumePrunePlan(out, opts.Json, preview) | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | 150 | if !opts.Json { | |
| 151 | 151 | if err := renderPrunePreview(out, preview); err != nil { | |
| 152 | 152 | return err | |
| 153 | 153 | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | if !opts.NoConfirm && !opts.Json { | |
| 157 | - | confirmed, err := components.RunConfirm("Remove these volumes?") | |
| 158 | - | if err != nil { | |
| 159 | - | return err | |
| 160 | - | } | |
| 161 | - | if !confirmed { | |
| 162 | - | return cliWriteLine(out, "Cancelled.") | |
| 154 | + | if !opts.NoConfirm { | |
| 155 | + | confirmed, err := components.RunConfirm("Remove these volumes?") | |
| 156 | + | if err != nil { | |
| 157 | + | return err | |
| 158 | + | } | |
| 159 | + | if !confirmed { | |
| 160 | + | return cliWriteLine(out, "Cancelled.") | |
| 161 | + | } | |
| 163 | 162 | } | |
| 164 | 163 | } | |
| 165 | 164 | ||
| @@ -167,10 +166,25 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun | |||
| 167 | 166 | if err != nil { | |
| 168 | 167 | return err | |
| 169 | 168 | } | |
| 170 | - | ||
| 171 | 169 | if opts.Json { | |
| 172 | 170 | return writeJSON(out, result) | |
| 173 | 171 | } | |
| 174 | 172 | ||
| 175 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))) | |
| 173 | + | if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))); err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | return cliRenderPruneSummary(out, result.Plan) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // renderVolumePrunePlan writes one plan without deleting anything. | |
| 180 | + | func renderVolumePrunePlan(out io.Writer, asJSON bool, resp *dto.VolumePruneResponse) error { | |
| 181 | + | if asJSON { | |
| 182 | + | return writeJSON(out, resp) | |
| 183 | + | } | |
| 184 | + | if resp.Plan.Eligible == 0 { | |
| 185 | + | if err := cliWriteLine(out, "No released volumes to remove."); err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | return cliRenderPruneSummary(out, resp.Plan) | |
| 176 | 190 | } | |
M internal/adapters/in/cli/volumes_test.go +26 -9 collapsed to keep the page light
D internal/adapters/in/http/admin/bootstrap_test.go +0 -413 collapsed to keep the page light
M internal/adapters/in/http/admin/handler.go +103 -1364 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps.go +603 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_apps_test.go +379 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired.go +48 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/handler_retired_test.go +60 -0 collapsed to keep the page light
M internal/adapters/in/http/admin/handler_test.go +240 -1929 large diff (2169 lines changed): collapsed
M internal/adapters/in/http/admin/handler_volumes.go +6 -0
| @@ -2,6 +2,7 @@ package admin | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "errors" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zerowrap" | |
| @@ -84,6 +85,10 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) { | |||
| 84 | 85 | if err != nil { | |
| 85 | 86 | log := zerowrap.FromCtx(ctx) | |
| 86 | 87 | log.Error().Err(err).Msg("failed to prune volumes") | |
| 88 | + | if errors.Is(err, domain.ErrPruneDisabled) { | |
| 89 | + | h.sendJSON(w, http.StatusConflict, dto.AppError{Error: "prune-disabled", Message: err.Error()}) | |
| 90 | + | return | |
| 91 | + | } | |
| 87 | 92 | h.sendError(w, http.StatusInternalServerError, "failed to prune volumes") | |
| 88 | 93 | return | |
| 89 | 94 | } | |
| @@ -100,5 +105,6 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) { | |||
| 100 | 105 | VolumesRemoved: report.VolumesRemoved, | |
| 101 | 106 | SpaceReclaimed: report.SpaceReclaimed, | |
| 102 | 107 | Volumes: vols, | |
| 108 | + | Plan: toPruneSummary(report.Plan), | |
| 103 | 109 | }) | |
| 104 | 110 | } | |
M internal/adapters/in/http/admin/images.go +8 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/local_authority.go +154 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/local_authority_test.go +148 -0 collapsed to keep the page light
D internal/adapters/in/http/admin/preview_handler.go +0 -167 collapsed to keep the page light
A internal/adapters/in/http/admin/prune.go +11 -0 collapsed to keep the page light
A internal/adapters/in/http/admin/prune_test.go +112 -0 collapsed to keep the page light
M internal/adapters/in/http/httphelper/localhost.go +6 -5 collapsed to keep the page light
M internal/adapters/in/http/middleware/auth.go +13 -33 collapsed to keep the page light
M internal/adapters/in/http/middleware/auth_test.go +50 -3 collapsed to keep the page light
M internal/adapters/in/http/middleware/cidr.go +92 -14 collapsed to keep the page light
M internal/adapters/in/http/middleware/cidr_test.go +134 -14 collapsed to keep the page light
M internal/adapters/in/http/onboarding/handler_test.go +9 -9 collapsed to keep the page light
M internal/adapters/in/http/proxy/handler.go +26 -2 collapsed to keep the page light
M internal/adapters/in/http/proxy/handler_test.go +20 -1 collapsed to keep the page light
M internal/adapters/in/http/registry/handler.go +62 -130 collapsed to keep the page light
M internal/adapters/in/http/registry/handler_test.go +29 -5 collapsed to keep the page light
A internal/adapters/in/http/registry/route/route.go +227 -0 collapsed to keep the page light
A internal/adapters/in/http/registry/route/route_test.go +125 -0 collapsed to keep the page light
A internal/adapters/in/traffic/l4_roundtrip_test.go +63 -0 collapsed to keep the page light
A internal/adapters/localadmin/localadmin.go +412 -0 collapsed to keep the page light
A internal/adapters/localadmin/localadmin_test.go +397 -0 collapsed to keep the page light
A internal/adapters/localadmin/peer_linux.go +80 -0 collapsed to keep the page light
A internal/adapters/localadmin/peer_other.go +20 -0 collapsed to keep the page light
A internal/adapters/out/appsecrets/store.go +81 -0 collapsed to keep the page light
A internal/adapters/out/appsecrets/store_test.go +23 -0 collapsed to keep the page light
A internal/adapters/out/appstate/export_test.go +63 -0 collapsed to keep the page light
A internal/adapters/out/appstate/protection.go +429 -0 collapsed to keep the page light
A internal/adapters/out/appstate/protection_test.go +378 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store.go +1637 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_claim_test.go +211 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_coordination_test.go +74 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_retire_test.go +134 -0 collapsed to keep the page light
A internal/adapters/out/appstate/store_test.go +523 -0 collapsed to keep the page light
A internal/adapters/out/docker/live_runtime_test.go +206 -0 collapsed to keep the page light
M internal/adapters/out/docker/runtime.go +133 -81 collapsed to keep the page light
A internal/adapters/out/docker/runtime_backend_binds_test.go +128 -0 collapsed to keep the page light
M internal/adapters/out/docker/runtime_create_test.go +48 -0 collapsed to keep the page light
D internal/adapters/out/docker/runtime_image_cleanup_test.go +0 -190 collapsed to keep the page light
A internal/adapters/out/docker/runtime_prune.go +202 -0 collapsed to keep the page light
A internal/adapters/out/docker/runtime_prune_test.go +214 -0 collapsed to keep the page light
A internal/adapters/out/docker/runtime_recovery_test.go +148 -0 collapsed to keep the page light
A internal/adapters/out/docker/stop_timeout_test.go +42 -0 collapsed to keep the page light
A internal/adapters/out/docker/testhelpers_test.go +23 -0 collapsed to keep the page light
M internal/adapters/out/filesystem/backup_storage.go +23 -17 collapsed to keep the page light
M internal/adapters/out/filesystem/backup_storage_test.go +49 -10 collapsed to keep the page light
M internal/adapters/out/filesystem/blob.go +121 -5 collapsed to keep the page light
M internal/adapters/out/filesystem/blob_test.go +80 -11 collapsed to keep the page light
M internal/adapters/out/filesystem/manifest.go +34 -0 collapsed to keep the page light
M internal/adapters/out/filesystem/manifest_test.go +20 -0 collapsed to keep the page light
D internal/adapters/out/filesystem/previewstore.go +0 -92 collapsed to keep the page light
D internal/adapters/out/filesystem/previewstore_test.go +0 -61 collapsed to keep the page light
A internal/adapters/out/imageref/resolver.go +114 -0 collapsed to keep the page light
A internal/adapters/out/imageref/resolver_test.go +135 -0 collapsed to keep the page light
M internal/adapters/out/s3/volume_backup_storage.go +24 -19 collapsed to keep the page light
M internal/adapters/out/s3/volume_backup_storage_test.go +10 -8 collapsed to keep the page light
A internal/app/app_monitor.go +92 -0 collapsed to keep the page light
A internal/app/app_monitor_test.go +403 -0 collapsed to keep the page light
A internal/app/app_traffic.go +189 -0 collapsed to keep the page light
A internal/app/app_traffic_publication_test.go +180 -0 collapsed to keep the page light
A internal/app/gcbarrier.go +109 -0 collapsed to keep the page light
A internal/app/gcbarrier_test.go +238 -0 collapsed to keep the page light
M internal/app/kernel.go +4 -0
| @@ -28,6 +28,7 @@ type Kernel struct { | |||
| 28 | 28 | logSvc in.LogService | |
| 29 | 29 | volumeSvc in.VolumeService | |
| 30 | 30 | publicTLSSvc in.PublicTLSService | |
| 31 | + | appSvc in.AppService | |
| 31 | 32 | cleanup func() | |
| 32 | 33 | } | |
| 33 | 34 | ||
| @@ -90,6 +91,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 90 | 91 | logSvc: svc.logSvc, | |
| 91 | 92 | volumeSvc: svc.volumeSvc, | |
| 92 | 93 | publicTLSSvc: svc.publicTLSSvc, | |
| 94 | + | appSvc: svc.appSvc, | |
| 93 | 95 | cleanup: wrappedCleanup, | |
| 94 | 96 | }, nil | |
| 95 | 97 | } else { | |
| @@ -150,4 +152,6 @@ func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | |||
| 150 | 152 | ||
| 151 | 153 | func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc } | |
| 152 | 154 | ||
| 155 | + | func (k *Kernel) Apps() in.AppService { return k.appSvc } | |
| 156 | + | ||
| 153 | 157 | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | |