docs(deployment): describe the overlap replacement of stateless services
- commit
- 7ce0d9c45af6cd55ecd45541437ed682008d2651
- author
- brice · · 2026-10-16 19:20 UTC
- parent
- 7599149
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +44 -14
M README.md +1 -1
| @@ -154,7 +154,7 @@ gordon apps deploy blog --remote prod | |||
| 154 | 154 | - Private Docker/Podman registry on your VPS | |
| 155 | 155 | - Declarative apps: one TOML file per app, explicit apply then deploy | |
| 156 | 156 | - Domain-to-container routing through a smart TCP edge reverse proxy | |
| 157 | - | - Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish) | |
| 157 | + | - Zero-downtime replacement of stateless services (start, verify readiness, switch traffic, stop the old one); services with volumes, binds, or devices are replaced sequentially | |
| 158 | 158 | - Remote CLI management (daemon is the sole writer) | |
| 159 | 159 | - Declarative per-service volumes, retained across lifecycle operations | |
| 160 | 160 | - Per-service secrets in pass, app-wide public env in the manifest | |
| @@ -154,7 +154,7 @@ gordon apps deploy blog --remote prod | |||||
| 154 | - Private Docker/Podman registry on your VPS | 154 | - Private Docker/Podman registry on your VPS | ||
| 155 | - Declarative apps: one TOML file per app, explicit apply then deploy | 155 | - Declarative apps: one TOML file per app, explicit apply then deploy | ||
| 156 | - Domain-to-container routing through a smart TCP edge reverse proxy | 156 | - Domain-to-container routing through a smart TCP edge reverse proxy | ||
| 157 | - | - Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish) | 157 | + | - Zero-downtime replacement of stateless services (start, verify readiness, switch traffic, stop the old one); services with volumes, binds, or devices are replaced sequentially |
| 158 | - Remote CLI management (daemon is the sole writer) | 158 | - Remote CLI management (daemon is the sole writer) | ||
| 159 | - Declarative per-service volumes, retained across lifecycle operations | 159 | - Declarative per-service volumes, retained across lifecycle operations | ||
| 160 | - Per-service secrets in pass, app-wide public env in the manifest | 160 | - Per-service secrets in pass, app-wide public env in the manifest | ||
M docs/concepts.md +15 -5
| @@ -71,19 +71,29 @@ DATABASE_URL = "database-url" | |||
| 71 | 71 | ||
| 72 | 72 | ## Updates | |
| 73 | 73 | ||
| 74 | - | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time. | |
| 74 | + | Gordon deploys an app's services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime; every other service is replaced sequentially and may be briefly interrupted, because Gordon never runs two Gordon-managed generations of a service that owns a volume, a bind, or a device. | |
| 75 | 75 | ||
| 76 | - | Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container. | |
| 76 | + | Preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations). If preflight fails, the running service is left untouched. | |
| 77 | + | ||
| 78 | + | A stateless service (no volume, bind, or device) is replaced with a short overlap: | |
| 79 | + | ||
| 80 | + | ``` | |
| 81 | + | Preflight ─► start new ─► readiness ─► publish ACTIVE ─► route traffic ─► SIGTERM old ─► stop_grace ─► kill + remove old | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | The old container keeps serving and stays routed until the new one is ready and traffic points at it. Only then does the old container receive SIGTERM, with its own `stop_grace` (default 30s) before it is killed. While both run, they share the service's network alias, so a request from another service of the app may reach either generation. A database migration must therefore stay backward compatible with the previous version of the service. If the new container never becomes ready, it is removed and the old one is not touched. If the old container cannot be removed after the switch, the deploy still succeeds and reports a cleanup warning, and the next reconciliation retries it. | |
| 85 | + | ||
| 86 | + | A service with a volume, a bind, or a device is replaced sequentially: | |
| 77 | 87 | ||
| 78 | 88 | ``` | |
| 79 | 89 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | |
| 80 | 90 | ``` | |
| 81 | 91 | ||
| 82 | - | If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. | |
| 92 | + | If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. | |
| 83 | 93 | ||
| 84 | - | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | |
| 94 | + | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This covers TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | |
| 85 | 95 | ||
| 86 | - | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. | |
| 96 | + | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation. For a stateless service the old container keeps serving and is kept. A deploy interrupted after the switch but before the old container was stopped leaves two containers running; Gordon stops the old one at the next reconciliation, after re-applying routing to the published container. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If a recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. | |
| 87 | 97 | ||
| 88 | 98 | Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported `unchanged`. | |
| 89 | 99 | ||
| @@ -71,19 +71,29 @@ DATABASE_URL = "database-url" | |||||
| 71 | 71 | ||||
| 72 | ## Updates | 72 | ## Updates | ||
| 73 | 73 | ||||
| 74 | - | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time. | 74 | + | Gordon deploys an app's services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime; every other service is replaced sequentially and may be briefly interrupted, because Gordon never runs two Gordon-managed generations of a service that owns a volume, a bind, or a device. |
| 75 | 75 | ||||
| 76 | - | Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container. | 76 | + | Preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations). If preflight fails, the running service is left untouched. |
| 77 | + | ||||
| 78 | + | A stateless service (no volume, bind, or device) is replaced with a short overlap: | |||
| 79 | + | ||||
| 80 | + | ``` | |||
| 81 | + | Preflight ─► start new ─► readiness ─► publish ACTIVE ─► route traffic ─► SIGTERM old ─► stop_grace ─► kill + remove old | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | The old container keeps serving and stays routed until the new one is ready and traffic points at it. Only then does the old container receive SIGTERM, with its own `stop_grace` (default 30s) before it is killed. While both run, they share the service's network alias, so a request from another service of the app may reach either generation. A database migration must therefore stay backward compatible with the previous version of the service. If the new container never becomes ready, it is removed and the old one is not touched. If the old container cannot be removed after the switch, the deploy still succeeds and reports a cleanup warning, and the next reconciliation retries it. | |||
| 85 | + | ||||
| 86 | + | A service with a volume, a bind, or a device is replaced sequentially: | |||
| 77 | 87 | ||||
| 78 | ``` | 88 | ``` | ||
| 79 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | 89 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | ||
| 80 | ``` | 90 | ``` | ||
| 81 | 91 | ||||
| 82 | - | If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. | 92 | + | If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. |
| 83 | 93 | ||||
| 84 | - | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | 94 | + | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This covers TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. |
| 85 | 95 | ||||
| 86 | - | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. | 96 | + | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation. For a stateless service the old container keeps serving and is kept. A deploy interrupted after the switch but before the old container was stopped leaves two containers running; Gordon stops the old one at the next reconciliation, after re-applying routing to the published container. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If a recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. |
| 87 | 97 | ||||
| 88 | Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported `unchanged`. | 98 | Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported `unchanged`. | ||
| 89 | 99 | ||||
M docs/config/apps.md +10 -2
| @@ -110,9 +110,17 @@ RCON is ordinary TCP: use TCP interfaces, no special RCON kind. | |||
| 110 | 110 | ||
| 111 | 111 | 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. | |
| 112 | 112 | ||
| 113 | + | ## Replacement on Deploy | |
| 114 | + | ||
| 115 | + | A deploy recreates a service whose image digest, spec, environment, or secret values changed and keeps an unchanged one. | |
| 116 | + | ||
| 117 | + | - A service with no volume, bind, or device (stateless) is replaced without downtime. The new container starts and passes its readiness probe while the old one keeps serving; traffic then switches to the new container; only afterwards does the old container receive SIGTERM, and it is killed once its `stop_grace` (default `30s`) runs out. If the new container is not ready, it is removed and the old one is left untouched. | |
| 118 | + | - During the overlap both containers answer to the same network alias, so other services of the app may reach either generation for a short time. Database migrations must be backward compatible with the previous version of the service. | |
| 119 | + | - A service with a volume, bind, or device is replaced sequentially: traffic is withdrawn, the old container is stopped and removed, then the new one starts. It may be briefly interrupted, and two generations of it never run at once. | |
| 120 | + | ||
| 113 | 121 | ## Environment and Secrets | |
| 114 | 122 | ||
| 115 | - | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | |
| 123 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. A change to `[env]` changes every service, so the next deploy recreates all of them, databases included. Prefer `[services.<name>.env]` for values only one service needs. | |
| 116 | 124 | - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy. | |
| 117 | 125 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | |
| 118 | 126 | - A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected. | |
| @@ -127,7 +135,7 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||
| 127 | 135 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | |
| 128 | 136 | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. | |
| 129 | 137 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | |
| 130 | - | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status). | |
| 138 | + | - A service with any bind runs as a single writer, like a volume-backed service: replacements never run two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status). | |
| 131 | 139 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | |
| 132 | 140 | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | |
| 133 | 141 | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | |
| @@ -110,9 +110,17 @@ RCON is ordinary TCP: use TCP interfaces, no special RCON kind. | |||||
| 110 | 110 | ||||
| 111 | 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. | 111 | 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. | ||
| 112 | 112 | ||||
| 113 | + | ## Replacement on Deploy | |||
| 114 | + | ||||
| 115 | + | A deploy recreates a service whose image digest, spec, environment, or secret values changed and keeps an unchanged one. | |||
| 116 | + | ||||
| 117 | + | - A service with no volume, bind, or device (stateless) is replaced without downtime. The new container starts and passes its readiness probe while the old one keeps serving; traffic then switches to the new container; only afterwards does the old container receive SIGTERM, and it is killed once its `stop_grace` (default `30s`) runs out. If the new container is not ready, it is removed and the old one is left untouched. | |||
| 118 | + | - During the overlap both containers answer to the same network alias, so other services of the app may reach either generation for a short time. Database migrations must be backward compatible with the previous version of the service. | |||
| 119 | + | - A service with a volume, bind, or device is replaced sequentially: traffic is withdrawn, the old container is stopped and removed, then the new one starts. It may be briefly interrupted, and two generations of it never run at once. | |||
| 120 | + | ||||
| 113 | ## Environment and Secrets | 121 | ## Environment and Secrets | ||
| 114 | 122 | ||||
| 115 | - | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | 123 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. A change to `[env]` changes every service, so the next deploy recreates all of them, databases included. Prefer `[services.<name>.env]` for values only one service needs. |
| 116 | - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy. | 124 | - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy. | ||
| 117 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | 125 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | ||
| 118 | - A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected. | 126 | - A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected. | ||
| @@ -127,7 +135,7 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||||
| 127 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | 135 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | ||
| 128 | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. | 136 | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. | ||
| 129 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | 137 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | ||
| 130 | - | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status). | 138 | + | - A service with any bind runs as a single writer, like a volume-backed service: replacements never run two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status). |
| 131 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | 139 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | ||
| 132 | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | 140 | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | ||
| 133 | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | 141 | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | ||
M docs/deployment/index.md +15 -3
| @@ -146,15 +146,27 @@ docker push gordon.example.com/myapp:$VERSION | |||
| 146 | 146 | ||
| 147 | 147 | ## Updates | |
| 148 | 148 | ||
| 149 | - | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time. | |
| 149 | + | Gordon deploys an app's services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime; a service that owns one of them is replaced sequentially and may be briefly interrupted, because Gordon never runs two Gordon-managed generations of such a service at the same time. | |
| 150 | + | ||
| 151 | + | Stateless service (no volume, bind, or device): | |
| 152 | + | ||
| 153 | + | ``` | |
| 154 | + | Preflight ─► start new ─► readiness ─► publish ACTIVE ─► route traffic ─► SIGTERM old ─► stop_grace ─► kill + remove old | |
| 155 | + | ``` | |
| 156 | + | ||
| 157 | + | Service with a volume, bind, or device: | |
| 150 | 158 | ||
| 151 | 159 | ``` | |
| 152 | 160 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | |
| 153 | 161 | ``` | |
| 154 | 162 | ||
| 155 | - | Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. | |
| 163 | + | Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. | |
| 164 | + | ||
| 165 | + | For a stateless service the old container serves until the new one is ready and routed, then receives SIGTERM and is killed after its `stop_grace` (default 30s). During the overlap both containers share the service's network alias, so other services of the app may reach either one. Database migrations must stay backward compatible with the previous version of the service. A new container that fails readiness is removed and the old one is not touched. An old container that cannot be removed is reported as a cleanup warning without failing the deploy, and reconciliation retries it. | |
| 166 | + | ||
| 167 | + | For a sequential replacement, a failure after the old container was removed is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. | |
| 156 | 168 | ||
| 157 | - | If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default. | |
| 169 | + | If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation; a stateless service's old container keeps serving. If it is interrupted after the switch but before the old container was stopped, the next reconciliation re-applies routing and stops the old container. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If a recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default. | |
| 158 | 170 | ||
| 159 | 171 | ## Related | |
| 160 | 172 | ||
| @@ -146,15 +146,27 @@ docker push gordon.example.com/myapp:$VERSION | |||||
| 146 | 146 | ||||
| 147 | ## Updates | 147 | ## Updates | ||
| 148 | 148 | ||||
| 149 | - | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time. | 149 | + | Gordon deploys an app's services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime; a service that owns one of them is replaced sequentially and may be briefly interrupted, because Gordon never runs two Gordon-managed generations of such a service at the same time. |
| 150 | + | ||||
| 151 | + | Stateless service (no volume, bind, or device): | |||
| 152 | + | ||||
| 153 | + | ``` | |||
| 154 | + | Preflight ─► start new ─► readiness ─► publish ACTIVE ─► route traffic ─► SIGTERM old ─► stop_grace ─► kill + remove old | |||
| 155 | + | ``` | |||
| 156 | + | ||||
| 157 | + | Service with a volume, bind, or device: | |||
| 150 | 158 | ||||
| 151 | ``` | 159 | ``` | ||
| 152 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | 160 | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | ||
| 153 | ``` | 161 | ``` | ||
| 154 | 162 | ||||
| 155 | - | Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. | 163 | + | Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. |
| 164 | + | ||||
| 165 | + | For a stateless service the old container serves until the new one is ready and routed, then receives SIGTERM and is killed after its `stop_grace` (default 30s). During the overlap both containers share the service's network alias, so other services of the app may reach either one. Database migrations must stay backward compatible with the previous version of the service. A new container that fails readiness is removed and the old one is not touched. An old container that cannot be removed is reported as a cleanup warning without failing the deploy, and reconciliation retries it. | |||
| 166 | + | ||||
| 167 | + | For a sequential replacement, a failure after the old container was removed is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. | |||
| 156 | 168 | ||||
| 157 | - | If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default. | 169 | + | If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation; a stateless service's old container keeps serving. If it is interrupted after the switch but before the old container was stopped, the next reconciliation re-applies routing and stops the old container. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If a recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default. |
| 158 | 170 | ||||
| 159 | ## Related | 171 | ## Related | ||
| 160 | 172 | ||||
M docs/deployment/rollback.md +1 -1
| @@ -12,7 +12,7 @@ gordon apps apply --file blog.toml --remote prod | |||
| 12 | 12 | gordon apps deploy blog --remote prod | |
| 13 | 13 | ``` | |
| 14 | 14 | ||
| 15 | - | A rollback deploy replaces services one at a time like any other deploy: a short service interruption is possible, and Gordon never runs two generations of the same service at the same time. If the replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback. | |
| 15 | + | A rollback deploy replaces services one at a time like any other deploy: a stateless service is replaced without downtime, while a service with a volume, bind, or device is replaced sequentially, may be briefly interrupted, and never has two generations running at once. If a sequential replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback. | |
| 16 | 16 | ||
| 17 | 17 | ## Re-push the Previous Image as latest | |
| 18 | 18 | ||
| @@ -12,7 +12,7 @@ gordon apps apply --file blog.toml --remote prod | |||||
| 12 | gordon apps deploy blog --remote prod | 12 | gordon apps deploy blog --remote prod | ||
| 13 | ``` | 13 | ``` | ||
| 14 | 14 | ||||
| 15 | - | A rollback deploy replaces services one at a time like any other deploy: a short service interruption is possible, and Gordon never runs two generations of the same service at the same time. If the replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback. | 15 | + | A rollback deploy replaces services one at a time like any other deploy: a stateless service is replaced without downtime, while a service with a volume, bind, or device is replaced sequentially, may be briefly interrupted, and never has two generations running at once. If a sequential replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback. |
| 16 | 16 | ||||
| 17 | ## Re-push the Previous Image as latest | 17 | ## Re-push the Previous Image as latest | ||
| 18 | 18 | ||||
M docs/getting-started.md +1 -1
| @@ -197,7 +197,7 @@ gordon images push myapp --build --remote prod | |||
| 197 | 197 | gordon apps deploy blog --remote prod | |
| 198 | 198 | ``` | |
| 199 | 199 | ||
| 200 | - | Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services. | |
| 200 | + | Gordon deploys services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime: the new container starts and becomes ready while the old one keeps serving, then traffic switches and the old one is stopped. A service that owns a volume, bind, or device is replaced sequentially, may be briefly interrupted, and never has two generations running at once. If preflight fails, the running service is left untouched. | |
| 201 | 201 | ||
| 202 | 202 | ## Next Steps | |
| 203 | 203 | ||
| @@ -197,7 +197,7 @@ gordon images push myapp --build --remote prod | |||||
| 197 | gordon apps deploy blog --remote prod | 197 | gordon apps deploy blog --remote prod | ||
| 198 | ``` | 198 | ``` | ||
| 199 | 199 | ||||
| 200 | - | Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services. | 200 | + | Gordon deploys services one at a time in sorted order. A service with no volume, bind, or device is replaced without downtime: the new container starts and becomes ready while the old one keeps serving, then traffic switches and the old one is stopped. A service that owns a volume, bind, or device is replaced sequentially, may be briefly interrupted, and never has two generations running at once. If preflight fails, the running service is left untouched. |
| 201 | 201 | ||||
| 202 | ## Next Steps | 202 | ## Next Steps | ||
| 203 | 203 | ||||
M docs/index.md +1 -1
| @@ -10,7 +10,7 @@ Gordon runs on your VPS and provides: | |||
| 10 | 10 | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | |
| 11 | 11 | - **HTTP Reverse Proxy** - Routes app hosts to containers | |
| 12 | 12 | - **Push, Apply, Deploy** - Push stores images; deploy is always explicit | |
| 13 | - | - **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish | |
| 13 | + | - **Zero-Downtime Stateless Deploys** - Stateless services start the new generation, verify readiness, switch traffic, then stop the old one; services with volumes, binds, or devices are replaced sequentially | |
| 14 | 14 | - **Single Binary** - ~15MB RAM footprint | |
| 15 | 15 | ||
| 16 | 16 | ## How It Works | |
| @@ -10,7 +10,7 @@ Gordon runs on your VPS and provides: | |||||
| 10 | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | 10 | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | ||
| 11 | - **HTTP Reverse Proxy** - Routes app hosts to containers | 11 | - **HTTP Reverse Proxy** - Routes app hosts to containers | ||
| 12 | - **Push, Apply, Deploy** - Push stores images; deploy is always explicit | 12 | - **Push, Apply, Deploy** - Push stores images; deploy is always explicit | ||
| 13 | - | - **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish | 13 | + | - **Zero-Downtime Stateless Deploys** - Stateless services start the new generation, verify readiness, switch traffic, then stop the old one; services with volumes, binds, or devices are replaced sequentially |
| 14 | - **Single Binary** - ~15MB RAM footprint | 14 | - **Single Binary** - ~15MB RAM footprint | ||
| 15 | 15 | ||||
| 16 | ## How It Works | 16 | ## How It Works | ||