refactor(apps): key manifest services by name (#288)
* refactor(apps): key manifest services by name * fix(config): drop retired routes table from config example * fix(apps): restrict retired-shape hint to array tables
- commit
- 7e66aa6719165ba902f160855413a8d0408d8cac
- author
- bnema · · 2026-09-17 19:02 UTC
- committer
- GitHub · · 2026-09-17 19:02 UTC
- parent
- 6fdddb8
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
24 files · +581 -226
M README.md +2 -3
| @@ -56,11 +56,10 @@ Minimal app file (`blog.toml`): | |||
| 56 | 56 | ```toml | |
| 57 | 57 | name = "blog" | |
| 58 | 58 | ||
| 59 | - | [[service]] | |
| 60 | - | name = "web" | |
| 59 | + | [services.web] | |
| 61 | 60 | image = "gordon.mydomain.com/myapp:v1.2.0" | |
| 62 | 61 | ||
| 63 | - | [[service.http]] | |
| 62 | + | [[services.web.http]] | |
| 64 | 63 | host = "blog.mydomain.com" | |
| 65 | 64 | port = 3000 | |
| 66 | 65 | ``` | |
| @@ -56,11 +56,10 @@ Minimal app file (`blog.toml`): | |||||
| 56 | ```toml | 56 | ```toml | ||
| 57 | name = "blog" | 57 | name = "blog" | ||
| 58 | 58 | ||||
| 59 | - | [[service]] | 59 | + | [services.web] |
| 60 | - | name = "web" | |||
| 61 | image = "gordon.mydomain.com/myapp:v1.2.0" | 60 | image = "gordon.mydomain.com/myapp:v1.2.0" | ||
| 62 | 61 | ||||
| 63 | - | [[service.http]] | 62 | + | [[services.web.http]] |
| 64 | host = "blog.mydomain.com" | 63 | host = "blog.mydomain.com" | ||
| 65 | port = 3000 | 64 | port = 3000 | ||
| 66 | ``` | 65 | ``` | ||
M docs/cli/backup.md +5 -6
| @@ -11,20 +11,19 @@ A service declares its databases and volumes in the app manifest, and lists | |||
| 11 | 11 | which of them are backed up: | |
| 12 | 12 | ||
| 13 | 13 | ```toml | |
| 14 | - | [[service]] | |
| 15 | - | name = "api" | |
| 14 | + | [services.api] | |
| 16 | 15 | image = "registry.example.com/shop/api:1.4.2" | |
| 17 | 16 | ||
| 18 | - | [[service.database]] | |
| 17 | + | [[services.api.database]] | |
| 19 | 18 | name = "orders" | |
| 20 | 19 | type = "postgres" | |
| 21 | 20 | schedule = "daily" | |
| 22 | 21 | ||
| 23 | - | [[service.volume]] | |
| 22 | + | [[services.api.volume]] | |
| 24 | 23 | name = "data" | |
| 25 | 24 | path = "/var/lib/data" | |
| 26 | 25 | ||
| 27 | - | [service.backup] | |
| 26 | + | [services.api.backup] | |
| 28 | 27 | postgres = ["orders"] | |
| 29 | 28 | volume = ["data"] | |
| 30 | 29 | ``` | |
| @@ -32,7 +31,7 @@ volume = ["data"] | |||
| 32 | 31 | A declared database or volume that the service's backup declaration does not | |
| 33 | 32 | reference is not a backup target. | |
| 34 | 33 | ||
| 35 | - | Administrative bind mounts are never backup targets. `[service.backup]` accepts | |
| 34 | + | Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts | |
| 36 | 35 | only declared databases and volumes, and Gordon never archives an | |
| 37 | 36 | operator-owned host path exposed through a bind. | |
| 38 | 37 | ||
| @@ -11,20 +11,19 @@ A service declares its databases and volumes in the app manifest, and lists | |||||
| 11 | which of them are backed up: | 11 | which of them are backed up: | ||
| 12 | 12 | ||||
| 13 | ```toml | 13 | ```toml | ||
| 14 | - | [[service]] | 14 | + | [services.api] |
| 15 | - | name = "api" | |||
| 16 | image = "registry.example.com/shop/api:1.4.2" | 15 | image = "registry.example.com/shop/api:1.4.2" | ||
| 17 | 16 | ||||
| 18 | - | [[service.database]] | 17 | + | [[services.api.database]] |
| 19 | name = "orders" | 18 | name = "orders" | ||
| 20 | type = "postgres" | 19 | type = "postgres" | ||
| 21 | schedule = "daily" | 20 | schedule = "daily" | ||
| 22 | 21 | ||||
| 23 | - | [[service.volume]] | 22 | + | [[services.api.volume]] |
| 24 | name = "data" | 23 | name = "data" | ||
| 25 | path = "/var/lib/data" | 24 | path = "/var/lib/data" | ||
| 26 | 25 | ||||
| 27 | - | [service.backup] | 26 | + | [services.api.backup] |
| 28 | postgres = ["orders"] | 27 | postgres = ["orders"] | ||
| 29 | volume = ["data"] | 28 | volume = ["data"] | ||
| 30 | ``` | 29 | ``` | ||
| @@ -32,7 +31,7 @@ volume = ["data"] | |||||
| 32 | A declared database or volume that the service's backup declaration does not | 31 | A declared database or volume that the service's backup declaration does not | ||
| 33 | reference is not a backup target. | 32 | reference is not a backup target. | ||
| 34 | 33 | ||||
| 35 | - | Administrative bind mounts are never backup targets. `[service.backup]` accepts | 34 | + | Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts |
| 36 | only declared databases and volumes, and Gordon never archives an | 35 | only declared databases and volumes, and Gordon never archives an | ||
| 37 | operator-owned host path exposed through a bind. | 36 | operator-owned host path exposed through a bind. | ||
| 38 | 37 | ||||
M docs/concepts.md +7 -8
| @@ -48,15 +48,14 @@ name = "blog" | |||
| 48 | 48 | [env] | |
| 49 | 49 | APP_ENV = "production" # app-wide public env, injected into all services | |
| 50 | 50 | ||
| 51 | - | [[service]] | |
| 52 | - | name = "web" | |
| 51 | + | [services.web] | |
| 53 | 52 | image = "gordon.mydomain.com/blog:1.4.2" | |
| 54 | 53 | ||
| 55 | - | [[service.http]] | |
| 54 | + | [[services.web.http]] | |
| 56 | 55 | host = "blog.mydomain.com" | |
| 57 | 56 | port = 3000 | |
| 58 | 57 | ||
| 59 | - | [service.secrets] # ENV name -> secret name (values stay in pass) | |
| 58 | + | [services.web.secrets] # ENV name -> secret name (values stay in pass) | |
| 60 | 59 | DATABASE_URL = "database-url" | |
| 61 | 60 | ``` | |
| 62 | 61 | ||
| @@ -101,7 +100,7 @@ Safe removal is the default. `gordon apps remove` withdraws workloads and frees | |||
| 101 | 100 | An app's HTTP interfaces declare the hosts Gordon serves: | |
| 102 | 101 | ||
| 103 | 102 | ```toml | |
| 104 | - | [[service.http]] | |
| 103 | + | [[services.web.http]] | |
| 105 | 104 | host = "app.example.com" | |
| 106 | 105 | port = 3000 | |
| 107 | 106 | ``` | |
| @@ -131,7 +130,7 @@ Deploy adds AND removes memberships without disconnecting unrelated services. Sh | |||
| 131 | 130 | Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes: | |
| 132 | 131 | ||
| 133 | 132 | ```toml | |
| 134 | - | [[service.volume]] | |
| 133 | + | [[services.web.volume]] | |
| 135 | 134 | name = "web-data" | |
| 136 | 135 | path = "/data" | |
| 137 | 136 | ``` | |
| @@ -152,7 +151,7 @@ APP_ENV = "production" | |||
| 152 | 151 | Service-specific values use `secrets` even when non-confidential: | |
| 153 | 152 | ||
| 154 | 153 | ```toml | |
| 155 | - | [service.secrets] | |
| 154 | + | [services.web.secrets] | |
| 156 | 155 | DATABASE_URL = "database-url" | |
| 157 | 156 | ``` | |
| 158 | 157 | ||
| @@ -174,7 +173,7 @@ Gordon uses an internal event system for coordination. Registry storage events n | |||
| 174 | 173 | ||
| 175 | 174 | ## Backups and Recovery | |
| 176 | 175 | ||
| 177 | - | 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. | |
| 176 | + | Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[services.<name>.database]]`, `[services.<name>.backup]`); storage infrastructure (destinations, schedules, retention) stays global. | |
| 178 | 177 | ||
| 179 | 178 | Stored backups are never deleted when declarations change — only schedules update on deploy. | |
| 180 | 179 | ||
| @@ -48,15 +48,14 @@ name = "blog" | |||||
| 48 | [env] | 48 | [env] | ||
| 49 | APP_ENV = "production" # app-wide public env, injected into all services | 49 | APP_ENV = "production" # app-wide public env, injected into all services | ||
| 50 | 50 | ||||
| 51 | - | [[service]] | 51 | + | [services.web] |
| 52 | - | name = "web" | |||
| 53 | image = "gordon.mydomain.com/blog:1.4.2" | 52 | image = "gordon.mydomain.com/blog:1.4.2" | ||
| 54 | 53 | ||||
| 55 | - | [[service.http]] | 54 | + | [[services.web.http]] |
| 56 | host = "blog.mydomain.com" | 55 | host = "blog.mydomain.com" | ||
| 57 | port = 3000 | 56 | port = 3000 | ||
| 58 | 57 | ||||
| 59 | - | [service.secrets] # ENV name -> secret name (values stay in pass) | 58 | + | [services.web.secrets] # ENV name -> secret name (values stay in pass) |
| 60 | DATABASE_URL = "database-url" | 59 | DATABASE_URL = "database-url" | ||
| 61 | ``` | 60 | ``` | ||
| 62 | 61 | ||||
| @@ -101,7 +100,7 @@ Safe removal is the default. `gordon apps remove` withdraws workloads and frees | |||||
| 101 | An app's HTTP interfaces declare the hosts Gordon serves: | 100 | An app's HTTP interfaces declare the hosts Gordon serves: | ||
| 102 | 101 | ||||
| 103 | ```toml | 102 | ```toml | ||
| 104 | - | [[service.http]] | 103 | + | [[services.web.http]] |
| 105 | host = "app.example.com" | 104 | host = "app.example.com" | ||
| 106 | port = 3000 | 105 | port = 3000 | ||
| 107 | ``` | 106 | ``` | ||
| @@ -131,7 +130,7 @@ Deploy adds AND removes memberships without disconnecting unrelated services. Sh | |||||
| 131 | Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes: | 130 | Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes: | ||
| 132 | 131 | ||||
| 133 | ```toml | 132 | ```toml | ||
| 134 | - | [[service.volume]] | 133 | + | [[services.web.volume]] |
| 135 | name = "web-data" | 134 | name = "web-data" | ||
| 136 | path = "/data" | 135 | path = "/data" | ||
| 137 | ``` | 136 | ``` | ||
| @@ -152,7 +151,7 @@ APP_ENV = "production" | |||||
| 152 | Service-specific values use `secrets` even when non-confidential: | 151 | Service-specific values use `secrets` even when non-confidential: | ||
| 153 | 152 | ||||
| 154 | ```toml | 153 | ```toml | ||
| 155 | - | [service.secrets] | 154 | + | [services.web.secrets] |
| 156 | DATABASE_URL = "database-url" | 155 | DATABASE_URL = "database-url" | ||
| 157 | ``` | 156 | ``` | ||
| 158 | 157 | ||||
| @@ -174,7 +173,7 @@ Gordon uses an internal event system for coordination. Registry storage events n | |||||
| 174 | 173 | ||||
| 175 | ## Backups and Recovery | 174 | ## Backups and Recovery | ||
| 176 | 175 | ||||
| 177 | - | 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. | 176 | + | Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[services.<name>.database]]`, `[services.<name>.backup]`); storage infrastructure (destinations, schedules, retention) stays global. |
| 178 | 177 | ||||
| 179 | Stored backups are never deleted when declarations change — only schedules update on deploy. | 178 | Stored backups are never deleted when declarations change — only schedules update on deploy. | ||
| 180 | 179 | ||||
M docs/config/apps.md +30 -28
| @@ -9,15 +9,16 @@ The file is intended for Git: it must never contain secret values. Service-speci | |||
| 9 | 9 | ```toml | |
| 10 | 10 | name = "blog" | |
| 11 | 11 | ||
| 12 | - | [[service]] | |
| 13 | - | name = "web" | |
| 12 | + | [services.web] | |
| 14 | 13 | image = "gordon.mydomain.com/blog:1.4.2" | |
| 15 | 14 | ||
| 16 | - | [[service.http]] | |
| 15 | + | [[services.web.http]] | |
| 17 | 16 | host = "blog.mydomain.com" | |
| 18 | 17 | port = 3000 | |
| 19 | 18 | ``` | |
| 20 | 19 | ||
| 20 | + | Each `[services.<name>]` table declares one service, and the table key is the service name. A name may contain dots, so quote the key when it does: `[services."web.api"]`, `[[services."web.api".http]]`. | |
| 21 | + | ||
| 21 | 22 | ## Full Example | |
| 22 | 23 | ||
| 23 | 24 | ```toml | |
| @@ -26,47 +27,46 @@ name = "blog" | |||
| 26 | 27 | [env] # optional, app-wide public env for all services | |
| 27 | 28 | APP_ENV = "production" | |
| 28 | 29 | ||
| 29 | - | [[service]] | |
| 30 | - | name = "web" | |
| 30 | + | [services.web] # one table per service; the key is the name | |
| 31 | 31 | image = "gordon.mydomain.com/blog:1.4.2" | |
| 32 | 32 | command = ["node", "server.js"] # optional override | |
| 33 | 33 | stop_grace = "30s" # optional, default 30s | |
| 34 | 34 | devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>] | |
| 35 | 35 | ||
| 36 | - | [service.readiness] # optional explicit readiness | |
| 36 | + | [services.web.readiness] # optional explicit readiness | |
| 37 | 37 | type = "http" | |
| 38 | 38 | path = "/healthz" | |
| 39 | 39 | port = 3000 | |
| 40 | 40 | timeout = "30s" | |
| 41 | 41 | ||
| 42 | - | [[service.http]] # 0..n HTTP interfaces | |
| 42 | + | [[services.web.http]] # 0..n HTTP interfaces | |
| 43 | 43 | host = "blog.mydomain.com" | |
| 44 | 44 | port = 3000 | |
| 45 | 45 | tls = "auto" # auto | always | never | |
| 46 | 46 | ||
| 47 | - | [[service.http]] # optional private interface | |
| 47 | + | [[services.web.http]] # optional private interface | |
| 48 | 48 | visibility = "internal" # public (default) | internal | |
| 49 | 49 | port = 8080 # required; no host or tls | |
| 50 | 50 | ||
| 51 | - | [service.secrets] # ENV name -> secret name (values in pass) | |
| 51 | + | [services.web.secrets] # ENV name -> secret name (values in pass) | |
| 52 | 52 | DATABASE_URL = "database-url" | |
| 53 | 53 | ||
| 54 | - | [[service.volume]] # 0..n named volumes only | |
| 54 | + | [[services.web.volume]] # 0..n named volumes only | |
| 55 | 55 | name = "web-data" | |
| 56 | 56 | path = "/data" | |
| 57 | 57 | readonly = false | |
| 58 | 58 | ||
| 59 | - | [[service.bind]] # 0..n; name references [app_mounts.<name>] | |
| 59 | + | [[services.web.bind]] # 0..n; name references [app_mounts.<name>] | |
| 60 | 60 | name = "app-logs" # policy name, never a host path | |
| 61 | 61 | path = "/var/log/app" # absolute container destination | |
| 62 | 62 | readonly = true | |
| 63 | 63 | ||
| 64 | - | [[service.database]] # explicit database declarations | |
| 64 | + | [[services.web.database]] # explicit database declarations | |
| 65 | 65 | name = "main" | |
| 66 | 66 | type = "postgres" | |
| 67 | 67 | schedule = "daily" | |
| 68 | 68 | ||
| 69 | - | [service.backup] # backup targets by reference | |
| 69 | + | [services.web.backup] # backup targets by reference | |
| 70 | 70 | postgres = ["main"] | |
| 71 | 71 | volume = ["web-data"] | |
| 72 | 72 | ||
| @@ -78,22 +78,22 @@ services = ["web"] | |||
| 78 | 78 | ## Identity Rules | |
| 79 | 79 | ||
| 80 | 80 | - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness. | |
| 81 | - | - Service name: `[a-z0-9_.-]`, max 63, unique within the app. | |
| 81 | + | - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected. | |
| 82 | 82 | - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | |
| 83 | 83 | - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | |
| 84 | 84 | - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes. | |
| 85 | 85 | ||
| 86 | 86 | ## Services | |
| 87 | 87 | ||
| 88 | - | 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. | |
| 88 | + | One or more explicitly named image-backed services per app, each in its own `[services.<name>]` table. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none. | |
| 89 | 89 | ||
| 90 | 90 | ```toml | |
| 91 | - | [[service.tcp]] | |
| 91 | + | [[services.web.tcp]] | |
| 92 | 92 | entrypoint = "game" # names installation entrypoints.<name> | |
| 93 | 93 | port = 25565 | |
| 94 | 94 | publish = "0.0.0.0:25565" | |
| 95 | 95 | ||
| 96 | - | [[service.udp]] | |
| 96 | + | [[services.web.udp]] | |
| 97 | 97 | entrypoint = "game" | |
| 98 | 98 | port = 28015 | |
| 99 | 99 | publish = "0.0.0.0:28015" | |
| @@ -107,33 +107,33 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||
| 107 | 107 | ||
| 108 | 108 | ## Environment and Secrets | |
| 109 | 109 | ||
| 110 | - | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[service.secrets]` key in the app. | |
| 111 | - | - `[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. | |
| 112 | - | - There is no `[service.env]` key — service-specific values must use secrets. | |
| 110 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | |
| 111 | + | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Secret updates affect the next deploy/restart, not running containers. | |
| 112 | + | - There is no `[services.<name>.env]` key — service-specific values must use secrets. | |
| 113 | 113 | ||
| 114 | 114 | ## Volumes and Databases | |
| 115 | 115 | ||
| 116 | 116 | - Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible. | |
| 117 | - | - Manifests never carry host paths. A `[[service.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md). | |
| 117 | + | - Manifests never carry host paths. A `[[services.<name>.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md). | |
| 118 | 118 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | |
| 119 | - | - `[[service.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. | |
| 119 | + | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. | |
| 120 | 120 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | |
| 121 | 121 | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. | |
| 122 | 122 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | |
| 123 | - | - `[[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`). | |
| 124 | - | - `[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. | |
| 123 | + | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). | |
| 124 | + | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. | |
| 125 | 125 | ||
| 126 | 126 | ## HTTP Interfaces | |
| 127 | 127 | ||
| 128 | - | `[[service.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty. | |
| 128 | + | `[[services.<name>.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty. | |
| 129 | 129 | ||
| 130 | 130 | ```toml | |
| 131 | - | [[service.http]] # public: proxied by host | |
| 131 | + | [[services.web.http]] # public: proxied by host | |
| 132 | 132 | host = "blog.mydomain.com" | |
| 133 | 133 | port = 3000 | |
| 134 | 134 | tls = "auto" # auto | always | never | |
| 135 | 135 | ||
| 136 | - | [[service.http]] # internal: not published (app private network only) | |
| 136 | + | [[services.web.http]] # internal: not published (app private network only) | |
| 137 | 137 | visibility = "internal" | |
| 138 | 138 | port = 8080 | |
| 139 | 139 | ``` | |
| @@ -147,7 +147,7 @@ A container port declared by both an internal HTTP interface and an externally b | |||
| 147 | 147 | ||
| 148 | 148 | ## Readiness | |
| 149 | 149 | ||
| 150 | - | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[service.readiness]` type; the internal port is never temporarily published to the host to probe it. | |
| 150 | + | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[services.<name>.readiness]` type; the internal port is never temporarily published to the host to probe it. | |
| 151 | 151 | ||
| 152 | 152 | ## TLS | |
| 153 | 153 | ||
| @@ -167,6 +167,8 @@ Services of the same app communicate over that private network and resolve each | |||
| 167 | 167 | ||
| 168 | 168 | 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. | |
| 169 | 169 | ||
| 170 | + | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | |
| 171 | + | ||
| 170 | 172 | ## Related | |
| 171 | 173 | ||
| 172 | 174 | - [Apps CLI](../cli/apps.md) | |
| @@ -9,15 +9,16 @@ The file is intended for Git: it must never contain secret values. Service-speci | |||||
| 9 | ```toml | 9 | ```toml | ||
| 10 | name = "blog" | 10 | name = "blog" | ||
| 11 | 11 | ||||
| 12 | - | [[service]] | 12 | + | [services.web] |
| 13 | - | name = "web" | |||
| 14 | image = "gordon.mydomain.com/blog:1.4.2" | 13 | image = "gordon.mydomain.com/blog:1.4.2" | ||
| 15 | 14 | ||||
| 16 | - | [[service.http]] | 15 | + | [[services.web.http]] |
| 17 | host = "blog.mydomain.com" | 16 | host = "blog.mydomain.com" | ||
| 18 | port = 3000 | 17 | port = 3000 | ||
| 19 | ``` | 18 | ``` | ||
| 20 | 19 | ||||
| 20 | + | Each `[services.<name>]` table declares one service, and the table key is the service name. A name may contain dots, so quote the key when it does: `[services."web.api"]`, `[[services."web.api".http]]`. | |||
| 21 | + | ||||
| 21 | ## Full Example | 22 | ## Full Example | ||
| 22 | 23 | ||||
| 23 | ```toml | 24 | ```toml | ||
| @@ -26,47 +27,46 @@ name = "blog" | |||||
| 26 | [env] # optional, app-wide public env for all services | 27 | [env] # optional, app-wide public env for all services | ||
| 27 | APP_ENV = "production" | 28 | APP_ENV = "production" | ||
| 28 | 29 | ||||
| 29 | - | [[service]] | 30 | + | [services.web] # one table per service; the key is the name |
| 30 | - | name = "web" | |||
| 31 | image = "gordon.mydomain.com/blog:1.4.2" | 31 | image = "gordon.mydomain.com/blog:1.4.2" | ||
| 32 | command = ["node", "server.js"] # optional override | 32 | command = ["node", "server.js"] # optional override | ||
| 33 | stop_grace = "30s" # optional, default 30s | 33 | stop_grace = "30s" # optional, default 30s | ||
| 34 | devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>] | 34 | devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>] | ||
| 35 | 35 | ||||
| 36 | - | [service.readiness] # optional explicit readiness | 36 | + | [services.web.readiness] # optional explicit readiness |
| 37 | type = "http" | 37 | type = "http" | ||
| 38 | path = "/healthz" | 38 | path = "/healthz" | ||
| 39 | port = 3000 | 39 | port = 3000 | ||
| 40 | timeout = "30s" | 40 | timeout = "30s" | ||
| 41 | 41 | ||||
| 42 | - | [[service.http]] # 0..n HTTP interfaces | 42 | + | [[services.web.http]] # 0..n HTTP interfaces |
| 43 | host = "blog.mydomain.com" | 43 | host = "blog.mydomain.com" | ||
| 44 | port = 3000 | 44 | port = 3000 | ||
| 45 | tls = "auto" # auto | always | never | 45 | tls = "auto" # auto | always | never | ||
| 46 | 46 | ||||
| 47 | - | [[service.http]] # optional private interface | 47 | + | [[services.web.http]] # optional private interface |
| 48 | visibility = "internal" # public (default) | internal | 48 | visibility = "internal" # public (default) | internal | ||
| 49 | port = 8080 # required; no host or tls | 49 | port = 8080 # required; no host or tls | ||
| 50 | 50 | ||||
| 51 | - | [service.secrets] # ENV name -> secret name (values in pass) | 51 | + | [services.web.secrets] # ENV name -> secret name (values in pass) |
| 52 | DATABASE_URL = "database-url" | 52 | DATABASE_URL = "database-url" | ||
| 53 | 53 | ||||
| 54 | - | [[service.volume]] # 0..n named volumes only | 54 | + | [[services.web.volume]] # 0..n named volumes only |
| 55 | name = "web-data" | 55 | name = "web-data" | ||
| 56 | path = "/data" | 56 | path = "/data" | ||
| 57 | readonly = false | 57 | readonly = false | ||
| 58 | 58 | ||||
| 59 | - | [[service.bind]] # 0..n; name references [app_mounts.<name>] | 59 | + | [[services.web.bind]] # 0..n; name references [app_mounts.<name>] |
| 60 | name = "app-logs" # policy name, never a host path | 60 | name = "app-logs" # policy name, never a host path | ||
| 61 | path = "/var/log/app" # absolute container destination | 61 | path = "/var/log/app" # absolute container destination | ||
| 62 | readonly = true | 62 | readonly = true | ||
| 63 | 63 | ||||
| 64 | - | [[service.database]] # explicit database declarations | 64 | + | [[services.web.database]] # explicit database declarations |
| 65 | name = "main" | 65 | name = "main" | ||
| 66 | type = "postgres" | 66 | type = "postgres" | ||
| 67 | schedule = "daily" | 67 | schedule = "daily" | ||
| 68 | 68 | ||||
| 69 | - | [service.backup] # backup targets by reference | 69 | + | [services.web.backup] # backup targets by reference |
| 70 | postgres = ["main"] | 70 | postgres = ["main"] | ||
| 71 | volume = ["web-data"] | 71 | volume = ["web-data"] | ||
| 72 | 72 | ||||
| @@ -78,22 +78,22 @@ services = ["web"] | |||||
| 78 | ## Identity Rules | 78 | ## Identity Rules | ||
| 79 | 79 | ||||
| 80 | - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness. | 80 | - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness. | ||
| 81 | - | - Service name: `[a-z0-9_.-]`, max 63, unique within the app. | 81 | + | - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected. |
| 82 | - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | 82 | - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | ||
| 83 | - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | 83 | - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. | ||
| 84 | - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes. | 84 | - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes. | ||
| 85 | 85 | ||||
| 86 | ## Services | 86 | ## Services | ||
| 87 | 87 | ||||
| 88 | - | 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. | 88 | + | One or more explicitly named image-backed services per app, each in its own `[services.<name>]` table. Exactly one container per service — no replicas field. A service may expose HTTP, TCP, UDP interfaces, or none. |
| 89 | 89 | ||||
| 90 | ```toml | 90 | ```toml | ||
| 91 | - | [[service.tcp]] | 91 | + | [[services.web.tcp]] |
| 92 | entrypoint = "game" # names installation entrypoints.<name> | 92 | entrypoint = "game" # names installation entrypoints.<name> | ||
| 93 | port = 25565 | 93 | port = 25565 | ||
| 94 | publish = "0.0.0.0:25565" | 94 | publish = "0.0.0.0:25565" | ||
| 95 | 95 | ||||
| 96 | - | [[service.udp]] | 96 | + | [[services.web.udp]] |
| 97 | entrypoint = "game" | 97 | entrypoint = "game" | ||
| 98 | port = 28015 | 98 | port = 28015 | ||
| 99 | publish = "0.0.0.0:28015" | 99 | publish = "0.0.0.0:28015" | ||
| @@ -107,33 +107,33 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||||
| 107 | 107 | ||||
| 108 | ## Environment and Secrets | 108 | ## Environment and Secrets | ||
| 109 | 109 | ||||
| 110 | - | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[service.secrets]` key in the app. | 110 | + | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. |
| 111 | - | - `[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. | 111 | + | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Secret updates affect the next deploy/restart, not running containers. |
| 112 | - | - There is no `[service.env]` key — service-specific values must use secrets. | 112 | + | - There is no `[services.<name>.env]` key — service-specific values must use secrets. |
| 113 | 113 | ||||
| 114 | ## Volumes and Databases | 114 | ## Volumes and Databases | ||
| 115 | 115 | ||||
| 116 | - Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible. | 116 | - Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible. | ||
| 117 | - | - Manifests never carry host paths. A `[[service.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md). | 117 | + | - Manifests never carry host paths. A `[[services.<name>.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md). |
| 118 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | 118 | - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them. | ||
| 119 | - | - `[[service.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. | 119 | + | - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time. |
| 120 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | 120 | - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations. | ||
| 121 | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. | 121 | - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. | ||
| 122 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | 122 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | ||
| 123 | - | - `[[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`). | 123 | + | - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`). |
| 124 | - | - `[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. | 124 | + | - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change. |
| 125 | 125 | ||||
| 126 | ## HTTP Interfaces | 126 | ## HTTP Interfaces | ||
| 127 | 127 | ||||
| 128 | - | `[[service.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty. | 128 | + | `[[services.<name>.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty. |
| 129 | 129 | ||||
| 130 | ```toml | 130 | ```toml | ||
| 131 | - | [[service.http]] # public: proxied by host | 131 | + | [[services.web.http]] # public: proxied by host |
| 132 | host = "blog.mydomain.com" | 132 | host = "blog.mydomain.com" | ||
| 133 | port = 3000 | 133 | port = 3000 | ||
| 134 | tls = "auto" # auto | always | never | 134 | tls = "auto" # auto | always | never | ||
| 135 | 135 | ||||
| 136 | - | [[service.http]] # internal: not published (app private network only) | 136 | + | [[services.web.http]] # internal: not published (app private network only) |
| 137 | visibility = "internal" | 137 | visibility = "internal" | ||
| 138 | port = 8080 | 138 | port = 8080 | ||
| 139 | ``` | 139 | ``` | ||
| @@ -147,7 +147,7 @@ A container port declared by both an internal HTTP interface and an externally b | |||||
| 147 | 147 | ||||
| 148 | ## Readiness | 148 | ## Readiness | ||
| 149 | 149 | ||||
| 150 | - | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[service.readiness]` type; the internal port is never temporarily published to the host to probe it. | 150 | + | `type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[services.<name>.readiness]` type; the internal port is never temporarily published to the host to probe it. |
| 151 | 151 | ||||
| 152 | ## TLS | 152 | ## TLS | ||
| 153 | 153 | ||||
| @@ -167,6 +167,8 @@ Services of the same app communicate over that private network and resolve each | |||||
| 167 | 167 | ||||
| 168 | 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. | 168 | 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. | ||
| 169 | 169 | ||||
| 170 | + | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | |||
| 171 | + | ||||
| 170 | ## Related | 172 | ## Related | ||
| 171 | 173 | ||||
| 172 | - [Apps CLI](../cli/apps.md) | 174 | - [Apps CLI](../cli/apps.md) | ||
M docs/config/env.md +2 -2
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Environment Variables | |
| 2 | 2 | ||
| 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. | |
| 3 | + | App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[services.<name>.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 | ||
| @@ -23,7 +23,7 @@ The `[env]` directory backs the installation secret store: | |||
| 23 | 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 | 24 | - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |
| 25 | 25 | ||
| 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)). | |
| 26 | + | App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[services.<name>.secrets]` at deploy time (see [App Manifest](./apps.md)). | |
| 27 | 27 | ||
| 28 | 28 | ## Secret Provider Syntax | |
| 29 | 29 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Environment Variables | 1 | # Environment Variables | ||
| 2 | 2 | ||||
| 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. | 3 | + | App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[services.<name>.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 | ## Configuration | 5 | ## Configuration | ||
| 6 | 6 | ||||
| @@ -23,7 +23,7 @@ The `[env]` directory backs the installation secret store: | |||||
| 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. | 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"`. | 24 | - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | ||
| 25 | 25 | ||||
| 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)). | 26 | + | App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[services.<name>.secrets]` at deploy time (see [App Manifest](./apps.md)). |
| 27 | 27 | ||||
| 28 | ## Secret Provider Syntax | 28 | ## Secret Provider Syntax | ||
| 29 | 29 | ||||
M docs/config/index.md +2 -2
| @@ -21,7 +21,7 @@ address = ":443" | |||
| 21 | 21 | protocol = "smart_tcp" | |
| 22 | 22 | ``` | |
| 23 | 23 | ||
| 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. | |
| 24 | + | Application workloads live in standalone app files, not in `gordon.toml` — see [App Manifest](./apps.md). Retired workload keys such as `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` are rejected at startup. Installation-level `[[services]]` for standalone L4 workloads remains valid. | |
| 25 | 25 | ||
| 26 | 26 | > **Note:** `gordon_domain` is the canonical registry and Admin API host. | |
| 27 | 27 | > | |
| @@ -106,7 +106,7 @@ network_prefix = "gordon" # Network name prefix | |||
| 106 | 106 | internal = false # Set true to block direct egress from isolated networks | |
| 107 | 107 | ||
| 108 | 108 | # REMOVED in v3 (declare apps in standalone files, see ./apps.md): | |
| 109 | - | # [routes], [attachments], [network_groups], [[services]]-as-apps, | |
| 109 | + | # [routes], [attachments], [network_groups], | |
| 110 | 110 | # [service_routes], [auto_route], [previews] | |
| 111 | 111 | ||
| 112 | 112 | # Backups | |
| @@ -21,7 +21,7 @@ address = ":443" | |||||
| 21 | protocol = "smart_tcp" | 21 | protocol = "smart_tcp" | ||
| 22 | ``` | 22 | ``` | ||
| 23 | 23 | ||||
| 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. | 24 | + | Application workloads live in standalone app files, not in `gordon.toml` — see [App Manifest](./apps.md). Retired workload keys such as `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` are rejected at startup. Installation-level `[[services]]` for standalone L4 workloads remains valid. |
| 25 | 25 | ||||
| 26 | > **Note:** `gordon_domain` is the canonical registry and Admin API host. | 26 | > **Note:** `gordon_domain` is the canonical registry and Admin API host. | ||
| 27 | > | 27 | > | ||
| @@ -106,7 +106,7 @@ network_prefix = "gordon" # Network name prefix | |||||
| 106 | internal = false # Set true to block direct egress from isolated networks | 106 | internal = false # Set true to block direct egress from isolated networks | ||
| 107 | 107 | ||||
| 108 | # REMOVED in v3 (declare apps in standalone files, see ./apps.md): | 108 | # REMOVED in v3 (declare apps in standalone files, see ./apps.md): | ||
| 109 | - | # [routes], [attachments], [network_groups], [[services]]-as-apps, | 109 | + | # [routes], [attachments], [network_groups], |
| 110 | # [service_routes], [auto_route], [previews] | 110 | # [service_routes], [auto_route], [previews] | ||
| 111 | 111 | ||||
| 112 | # Backups | 112 | # Backups | ||
M docs/config/reference.md +9 -8
| @@ -142,7 +142,7 @@ preserve = true # Keep volumes when containers are | |||
| 142 | 142 | # ADMINISTRATIVE APP BIND MOUNTS | |
| 143 | 143 | # ============================================================================= | |
| 144 | 144 | # A named policy is the only way an app manifest may reference a host path. | |
| 145 | - | # App manifests declare [[service.bind]] with name = "<mount>"; direct host | |
| 145 | + | # App manifests declare [[services.<name>.bind]] with name = "<mount>"; direct host | |
| 146 | 146 | # paths are always rejected, and the policy's allowlists are exact and | |
| 147 | 147 | # non-empty. read_only on the policy and readonly on the bind both force | |
| 148 | 148 | # read-only: either side wins and a bind never weakens its policy. | |
| @@ -168,8 +168,9 @@ preserve = true # Keep volumes when containers are | |||
| 168 | 168 | # allowed_services = ["transcoder"] # Required; exact, non-empty | |
| 169 | 169 | ||
| 170 | 170 | # REMOVED in v3: [routes], [attachments], [network_groups], | |
| 171 | - | # [[services]]-as-apps, [service_routes], [auto_route], [previews]. | |
| 172 | - | # Declare apps in standalone files (see ./apps.md). | |
| 171 | + | # app-like [[services]], [service_routes], [auto_route], [previews]. | |
| 172 | + | # Declare apps in standalone files (see ./apps.md). The standalone | |
| 173 | + | # [[services]] table below stays valid for installation-level L4 workloads. | |
| 173 | 174 | ||
| 174 | 175 | # ============================================================================= | |
| 175 | 176 | # EXTERNAL ROUTES | |
| @@ -342,14 +343,14 @@ Note: for all `backups.retention.*` keys, `0` means keep no backups for that ret | |||
| 342 | 343 | ||
| 343 | 344 | ## App Manifest HTTP Interfaces | |
| 344 | 345 | ||
| 345 | - | `[[service.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)). | |
| 346 | + | `[[services.<name>.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)). | |
| 346 | 347 | ||
| 347 | 348 | | Key | Values | Default | Description | | |
| 348 | 349 | |-----|--------|---------|-------------| | |
| 349 | - | | `service.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network | | |
| 350 | - | | `service.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` | | |
| 351 | - | | `service.http[].port` | integer | none | Required container port for every interface | | |
| 352 | - | | `service.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value | | |
| 350 | + | | `services.<name>.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network | | |
| 351 | + | | `services.<name>.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` | | |
| 352 | + | | `services.<name>.http[].port` | integer | none | Required container port for every interface | | |
| 353 | + | | `services.<name>.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value | | |
| 353 | 354 | ||
| 354 | 355 | `visibility = "internal"` creates no proxy route, host reservation, certificate target, or host port publication. A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected, as are duplicate internal HTTP ports. | |
| 355 | 356 | ||
| @@ -142,7 +142,7 @@ preserve = true # Keep volumes when containers are | |||||
| 142 | # ADMINISTRATIVE APP BIND MOUNTS | 142 | # ADMINISTRATIVE APP BIND MOUNTS | ||
| 143 | # ============================================================================= | 143 | # ============================================================================= | ||
| 144 | # A named policy is the only way an app manifest may reference a host path. | 144 | # A named policy is the only way an app manifest may reference a host path. | ||
| 145 | - | # App manifests declare [[service.bind]] with name = "<mount>"; direct host | 145 | + | # App manifests declare [[services.<name>.bind]] with name = "<mount>"; direct host |
| 146 | # paths are always rejected, and the policy's allowlists are exact and | 146 | # paths are always rejected, and the policy's allowlists are exact and | ||
| 147 | # non-empty. read_only on the policy and readonly on the bind both force | 147 | # non-empty. read_only on the policy and readonly on the bind both force | ||
| 148 | # read-only: either side wins and a bind never weakens its policy. | 148 | # read-only: either side wins and a bind never weakens its policy. | ||
| @@ -168,8 +168,9 @@ preserve = true # Keep volumes when containers are | |||||
| 168 | # allowed_services = ["transcoder"] # Required; exact, non-empty | 168 | # allowed_services = ["transcoder"] # Required; exact, non-empty | ||
| 169 | 169 | ||||
| 170 | # REMOVED in v3: [routes], [attachments], [network_groups], | 170 | # REMOVED in v3: [routes], [attachments], [network_groups], | ||
| 171 | - | # [[services]]-as-apps, [service_routes], [auto_route], [previews]. | 171 | + | # app-like [[services]], [service_routes], [auto_route], [previews]. |
| 172 | - | # Declare apps in standalone files (see ./apps.md). | 172 | + | # Declare apps in standalone files (see ./apps.md). The standalone |
| 173 | + | # [[services]] table below stays valid for installation-level L4 workloads. | |||
| 173 | 174 | ||||
| 174 | # ============================================================================= | 175 | # ============================================================================= | ||
| 175 | # EXTERNAL ROUTES | 176 | # EXTERNAL ROUTES | ||
| @@ -342,14 +343,14 @@ Note: for all `backups.retention.*` keys, `0` means keep no backups for that ret | |||||
| 342 | 343 | ||||
| 343 | ## App Manifest HTTP Interfaces | 344 | ## App Manifest HTTP Interfaces | ||
| 344 | 345 | ||||
| 345 | - | `[[service.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)). | 346 | + | `[[services.<name>.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)). |
| 346 | 347 | ||||
| 347 | | Key | Values | Default | Description | | 348 | | Key | Values | Default | Description | | ||
| 348 | |-----|--------|---------|-------------| | 349 | |-----|--------|---------|-------------| | ||
| 349 | - | | `service.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network | | 350 | + | | `services.<name>.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network | |
| 350 | - | | `service.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` | | 351 | + | | `services.<name>.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` | |
| 351 | - | | `service.http[].port` | integer | none | Required container port for every interface | | 352 | + | | `services.<name>.http[].port` | integer | none | Required container port for every interface | |
| 352 | - | | `service.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value | | 353 | + | | `services.<name>.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value | |
| 353 | 354 | ||||
| 354 | `visibility = "internal"` creates no proxy route, host reservation, certificate target, or host port publication. A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected, as are duplicate internal HTTP ports. | 355 | `visibility = "internal"` creates no proxy route, host reservation, certificate target, or host port publication. A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected, as are duplicate internal HTTP ports. | ||
| 355 | 356 | ||||
M docs/config/secrets.md +1 -1
| @@ -139,7 +139,7 @@ API_SECRET=${sops:production.yaml:api.key} | |||
| 139 | 139 | ||
| 140 | 140 | ## App Secret Values | |
| 141 | 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: | |
| 142 | + | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | |
| 143 | 143 | ||
| 144 | 144 | ```bash | |
| 145 | 145 | gordon apps secrets set blog --service web DATABASE_URL=... | |
| @@ -139,7 +139,7 @@ API_SECRET=${sops:production.yaml:api.key} | |||||
| 139 | 139 | ||||
| 140 | ## App Secret Values | 140 | ## App Secret Values | ||
| 141 | 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: | 142 | + | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: |
| 143 | 143 | ||||
| 144 | ```bash | 144 | ```bash | ||
| 145 | gordon apps secrets set blog --service web DATABASE_URL=... | 145 | gordon apps secrets set blog --service web DATABASE_URL=... | ||
M docs/config/security-hardening.md +1 -1
| @@ -53,7 +53,7 @@ allowed_services = ["web"] # exact, non-empty | |||
| 53 | 53 | root = "/srv/gordon" # optional boundary | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 56 | - | The manifest references only the policy name: `[[service.bind]] name = "app-logs"` with an absolute container `path`. | |
| 56 | + | The manifest references only the policy name: `[[services.<name>.bind]] name = "app-logs"` with an absolute container `path`. | |
| 57 | 57 | ||
| 58 | 58 | - `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form. | |
| 59 | 59 | - `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused. | |
| @@ -53,7 +53,7 @@ allowed_services = ["web"] # exact, non-empty | |||||
| 53 | root = "/srv/gordon" # optional boundary | 53 | root = "/srv/gordon" # optional boundary | ||
| 54 | ``` | 54 | ``` | ||
| 55 | 55 | ||||
| 56 | - | The manifest references only the policy name: `[[service.bind]] name = "app-logs"` with an absolute container `path`. | 56 | + | The manifest references only the policy name: `[[services.<name>.bind]] name = "app-logs"` with an absolute container `path`. |
| 57 | 57 | ||||
| 58 | - `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form. | 58 | - `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form. | ||
| 59 | - `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused. | 59 | - `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused. | ||
M docs/config/volumes.md +6 -7
| @@ -7,7 +7,7 @@ Persistent app storage is declared in each app manifest. The installation-level | |||
| 7 | 7 | Services declare persistent mounts in the app manifest: | |
| 8 | 8 | ||
| 9 | 9 | ```toml | |
| 10 | - | [[service.volume]] | |
| 10 | + | [[services.web.volume]] | |
| 11 | 11 | name = "database-data" | |
| 12 | 12 | path = "/var/lib/postgresql/data" | |
| 13 | 13 | ``` | |
| @@ -30,7 +30,7 @@ allowed_services = ["web"] # required; exact, non-empty | |||
| 30 | 30 | ||
| 31 | 31 | ```toml | |
| 32 | 32 | # app manifest | |
| 33 | - | [[service.bind]] | |
| 33 | + | [[services.web.bind]] | |
| 34 | 34 | name = "app-logs" # must match [app_mounts.<name>] | |
| 35 | 35 | path = "/var/lib/collector/host-logs" # container destination | |
| 36 | 36 | readonly = true | |
| @@ -47,15 +47,14 @@ A read-only log collector is the typical use. Its own state is a named volume; t | |||
| 47 | 47 | ```toml | |
| 48 | 48 | name = "metrics-agent" | |
| 49 | 49 | ||
| 50 | - | [[service]] | |
| 51 | - | name = "collector" | |
| 50 | + | [services.collector] | |
| 52 | 51 | image = "registry.example.com/metrics/collector:2.1.0" | |
| 53 | 52 | ||
| 54 | - | [[service.volume]] | |
| 53 | + | [[services.collector.volume]] | |
| 55 | 54 | name = "collector-state" | |
| 56 | 55 | path = "/var/lib/collector" | |
| 57 | 56 | ||
| 58 | - | [[service.bind]] | |
| 57 | + | [[services.collector.bind]] | |
| 59 | 58 | name = "app-logs" | |
| 60 | 59 | path = "/var/lib/collector/host-logs" | |
| 61 | 60 | readonly = true | |
| @@ -63,7 +62,7 @@ readonly = true | |||
| 63 | 62 | ||
| 64 | 63 | Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind. | |
| 65 | 64 | ||
| 66 | - | Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[service.volume]]` declaration maps it to a Gordon-owned volume; a `[[service.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path. | |
| 65 | + | Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[services.<name>.volume]]` declaration maps it to a Gordon-owned volume; a `[[services.<name>.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path. | |
| 67 | 66 | ||
| 68 | 67 | Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records. | |
| 69 | 68 | ||
| @@ -7,7 +7,7 @@ Persistent app storage is declared in each app manifest. The installation-level | |||||
| 7 | Services declare persistent mounts in the app manifest: | 7 | Services declare persistent mounts in the app manifest: | ||
| 8 | 8 | ||||
| 9 | ```toml | 9 | ```toml | ||
| 10 | - | [[service.volume]] | 10 | + | [[services.web.volume]] |
| 11 | name = "database-data" | 11 | name = "database-data" | ||
| 12 | path = "/var/lib/postgresql/data" | 12 | path = "/var/lib/postgresql/data" | ||
| 13 | ``` | 13 | ``` | ||
| @@ -30,7 +30,7 @@ allowed_services = ["web"] # required; exact, non-empty | |||||
| 30 | 30 | ||||
| 31 | ```toml | 31 | ```toml | ||
| 32 | # app manifest | 32 | # app manifest | ||
| 33 | - | [[service.bind]] | 33 | + | [[services.web.bind]] |
| 34 | name = "app-logs" # must match [app_mounts.<name>] | 34 | name = "app-logs" # must match [app_mounts.<name>] | ||
| 35 | path = "/var/lib/collector/host-logs" # container destination | 35 | path = "/var/lib/collector/host-logs" # container destination | ||
| 36 | readonly = true | 36 | readonly = true | ||
| @@ -47,15 +47,14 @@ A read-only log collector is the typical use. Its own state is a named volume; t | |||||
| 47 | ```toml | 47 | ```toml | ||
| 48 | name = "metrics-agent" | 48 | name = "metrics-agent" | ||
| 49 | 49 | ||||
| 50 | - | [[service]] | 50 | + | [services.collector] |
| 51 | - | name = "collector" | |||
| 52 | image = "registry.example.com/metrics/collector:2.1.0" | 51 | image = "registry.example.com/metrics/collector:2.1.0" | ||
| 53 | 52 | ||||
| 54 | - | [[service.volume]] | 53 | + | [[services.collector.volume]] |
| 55 | name = "collector-state" | 54 | name = "collector-state" | ||
| 56 | path = "/var/lib/collector" | 55 | path = "/var/lib/collector" | ||
| 57 | 56 | ||||
| 58 | - | [[service.bind]] | 57 | + | [[services.collector.bind]] |
| 59 | name = "app-logs" | 58 | name = "app-logs" | ||
| 60 | path = "/var/lib/collector/host-logs" | 59 | path = "/var/lib/collector/host-logs" | ||
| 61 | readonly = true | 60 | readonly = true | ||
| @@ -63,7 +62,7 @@ readonly = true | |||||
| 63 | 62 | ||||
| 64 | Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind. | 63 | Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind. | ||
| 65 | 64 | ||||
| 66 | - | Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[service.volume]]` declaration maps it to a Gordon-owned volume; a `[[service.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path. | 65 | + | Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[services.<name>.volume]]` declaration maps it to a Gordon-owned volume; a `[[services.<name>.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path. |
| 67 | 66 | ||||
| 68 | 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. | 67 | Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records. | ||
| 69 | 68 | ||||
M docs/deployment/index.md +1 -2
| @@ -113,8 +113,7 @@ docker push gordon.example.com/myapp:latest | |||
| 113 | 113 | Reference it from the app manifest: | |
| 114 | 114 | ||
| 115 | 115 | ```toml | |
| 116 | - | [[service]] | |
| 117 | - | name = "web" | |
| 116 | + | [services.web] | |
| 118 | 117 | image = "gordon.example.com/myapp:latest" | |
| 119 | 118 | ``` | |
| 120 | 119 | ||
| @@ -113,8 +113,7 @@ docker push gordon.example.com/myapp:latest | |||||
| 113 | Reference it from the app manifest: | 113 | Reference it from the app manifest: | ||
| 114 | 114 | ||||
| 115 | ```toml | 115 | ```toml | ||
| 116 | - | [[service]] | 116 | + | [services.web] |
| 117 | - | name = "web" | |||
| 118 | image = "gordon.example.com/myapp:latest" | 117 | image = "gordon.example.com/myapp:latest" | ||
| 119 | ``` | 118 | ``` | ||
| 120 | 119 | ||||
M docs/getting-started.md +4 -5
| @@ -81,7 +81,7 @@ address = ":443" # Public smart TCP edge (choose your bi | |||
| 81 | 81 | protocol = "smart_tcp" | |
| 82 | 82 | ``` | |
| 83 | 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 v3 — Gordon refuses to start when any of them is present. | |
| 84 | + | Application workloads are NOT declared in `gordon.toml`. Each app lives in its own standalone TOML file (see step 8). The old `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` keys were removed in v3 — Gordon refuses to start when any of them is present. Installation-level `[[services]]` for standalone L4 workloads remains valid. | |
| 85 | 85 | ||
| 86 | 86 | ## 5. Set Up DNS (Including Wildcard) | |
| 87 | 87 | ||
| @@ -156,11 +156,10 @@ Write the app file (`blog.toml`). The file is intended for Git: it must never co | |||
| 156 | 156 | ```toml | |
| 157 | 157 | name = "blog" | |
| 158 | 158 | ||
| 159 | - | [[service]] | |
| 160 | - | name = "web" | |
| 159 | + | [services.web] | |
| 161 | 160 | image = "gordon.mydomain.com/myapp:latest" | |
| 162 | 161 | ||
| 163 | - | [[service.http]] | |
| 162 | + | [[services.web.http]] | |
| 164 | 163 | host = "app.mydomain.com" | |
| 165 | 164 | port = 3000 | |
| 166 | 165 | ``` | |
| @@ -178,7 +177,7 @@ What these commands do: | |||
| 178 | 177 | - `gordon apps apply` validates the manifest and persists it as desired state. | |
| 179 | 178 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | |
| 180 | 179 | ||
| 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: | |
| 180 | + | If the app needs secrets, register their names in the manifest (`[services.<name>.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: | |
| 182 | 181 | ||
| 183 | 182 | ```bash | |
| 184 | 183 | gordon apps secrets set blog --service web APP_ENV=production --remote prod | |
| @@ -81,7 +81,7 @@ address = ":443" # Public smart TCP edge (choose your bi | |||||
| 81 | protocol = "smart_tcp" | 81 | protocol = "smart_tcp" | ||
| 82 | ``` | 82 | ``` | ||
| 83 | 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 v3 — Gordon refuses to start when any of them is present. | 84 | + | Application workloads are NOT declared in `gordon.toml`. Each app lives in its own standalone TOML file (see step 8). The old `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]`, and `[previews]` keys were removed in v3 — Gordon refuses to start when any of them is present. Installation-level `[[services]]` for standalone L4 workloads remains valid. |
| 85 | 85 | ||||
| 86 | ## 5. Set Up DNS (Including Wildcard) | 86 | ## 5. Set Up DNS (Including Wildcard) | ||
| 87 | 87 | ||||
| @@ -156,11 +156,10 @@ Write the app file (`blog.toml`). The file is intended for Git: it must never co | |||||
| 156 | ```toml | 156 | ```toml | ||
| 157 | name = "blog" | 157 | name = "blog" | ||
| 158 | 158 | ||||
| 159 | - | [[service]] | 159 | + | [services.web] |
| 160 | - | name = "web" | |||
| 161 | image = "gordon.mydomain.com/myapp:latest" | 160 | image = "gordon.mydomain.com/myapp:latest" | ||
| 162 | 161 | ||||
| 163 | - | [[service.http]] | 162 | + | [[services.web.http]] |
| 164 | host = "app.mydomain.com" | 163 | host = "app.mydomain.com" | ||
| 165 | port = 3000 | 164 | port = 3000 | ||
| 166 | ``` | 165 | ``` | ||
| @@ -178,7 +177,7 @@ What these commands do: | |||||
| 178 | - `gordon apps apply` validates the manifest and persists it as desired state. | 177 | - `gordon apps apply` validates the manifest and persists it as desired state. | ||
| 179 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | 178 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | ||
| 180 | 179 | ||||
| 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: | 180 | + | If the app needs secrets, register their names in the manifest (`[services.<name>.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: |
| 182 | 181 | ||||
| 183 | ```bash | 182 | ```bash | ||
| 184 | gordon apps secrets set blog --service web APP_ENV=production --remote prod | 183 | gordon apps secrets set blog --service web APP_ENV=production --remote prod | ||
M docs/migrate-to-v3.md +7 -6
| @@ -20,7 +20,7 @@ Remove workload declarations that v3 no longer accepts from `gordon.toml`: | |||
| 20 | 20 | - `[routes]`; | |
| 21 | 21 | - `[attachments]`; | |
| 22 | 22 | - `[network_groups]`; | |
| 23 | - | - app-like `[[services]]` and `[service_routes]`; | |
| 23 | + | - app-workload entries formerly declared as `[[services]]`, and `[service_routes]` (installation-level `[[services]]` used for standalone L4 workloads stays valid); | |
| 24 | 24 | - `[auto_route]` and `[auto_route_allowed_domains]`; | |
| 25 | 25 | - `[previews]`. | |
| 26 | 26 | ||
| @@ -40,23 +40,24 @@ Create one app manifest per workload. See [App Manifest](./config/apps.md). | |||
| 40 | 40 | ```toml | |
| 41 | 41 | name = "example-app" | |
| 42 | 42 | ||
| 43 | - | [[service]] | |
| 44 | - | name = "web" | |
| 43 | + | [services.web] | |
| 45 | 44 | image = "registry.example.com/example-app:1.2.3" | |
| 46 | 45 | ||
| 47 | - | [service.secrets] | |
| 46 | + | [services.web.secrets] | |
| 48 | 47 | DATABASE_URL = "database-url" | |
| 49 | 48 | ||
| 50 | - | [[service.http]] | |
| 49 | + | [[services.web.http]] | |
| 51 | 50 | host = "app.example.com" | |
| 52 | 51 | port = 8080 | |
| 53 | 52 | ``` | |
| 54 | 53 | ||
| 55 | 54 | The manifest contains secret **names**, never secret values. | |
| 56 | 55 | ||
| 56 | + | Manifests written for an early v3 alpha used `[[service]]` with a `name` field; that shape is rejected, not converted. Rewrite each service as a keyed `[services.<name>]` table with `[services.<name>.*]` children — see [Migrating a v3 alpha app manifest](./upgrading.md#migrating-a-v3-alpha-app-manifest). | |
| 57 | + | ||
| 57 | 58 | ## 2. Migrate domain secrets | |
| 58 | 59 | ||
| 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 | + | Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values: | |
| 60 | 61 | ||
| 61 | 62 | ```bash | |
| 62 | 63 | gordon apps apply --file ./example-app.toml --remote production | |
| @@ -20,7 +20,7 @@ Remove workload declarations that v3 no longer accepts from `gordon.toml`: | |||||
| 20 | - `[routes]`; | 20 | - `[routes]`; | ||
| 21 | - `[attachments]`; | 21 | - `[attachments]`; | ||
| 22 | - `[network_groups]`; | 22 | - `[network_groups]`; | ||
| 23 | - | - app-like `[[services]]` and `[service_routes]`; | 23 | + | - app-workload entries formerly declared as `[[services]]`, and `[service_routes]` (installation-level `[[services]]` used for standalone L4 workloads stays valid); |
| 24 | - `[auto_route]` and `[auto_route_allowed_domains]`; | 24 | - `[auto_route]` and `[auto_route_allowed_domains]`; | ||
| 25 | - `[previews]`. | 25 | - `[previews]`. | ||
| 26 | 26 | ||||
| @@ -40,23 +40,24 @@ Create one app manifest per workload. See [App Manifest](./config/apps.md). | |||||
| 40 | ```toml | 40 | ```toml | ||
| 41 | name = "example-app" | 41 | name = "example-app" | ||
| 42 | 42 | ||||
| 43 | - | [[service]] | 43 | + | [services.web] |
| 44 | - | name = "web" | |||
| 45 | image = "registry.example.com/example-app:1.2.3" | 44 | image = "registry.example.com/example-app:1.2.3" | ||
| 46 | 45 | ||||
| 47 | - | [service.secrets] | 46 | + | [services.web.secrets] |
| 48 | DATABASE_URL = "database-url" | 47 | DATABASE_URL = "database-url" | ||
| 49 | 48 | ||||
| 50 | - | [[service.http]] | 49 | + | [[services.web.http]] |
| 51 | host = "app.example.com" | 50 | host = "app.example.com" | ||
| 52 | port = 8080 | 51 | port = 8080 | ||
| 53 | ``` | 52 | ``` | ||
| 54 | 53 | ||||
| 55 | The manifest contains secret **names**, never secret values. | 54 | The manifest contains secret **names**, never secret values. | ||
| 56 | 55 | ||||
| 56 | + | Manifests written for an early v3 alpha used `[[service]]` with a `name` field; that shape is rejected, not converted. Rewrite each service as a keyed `[services.<name>]` table with `[services.<name>.*]` children — see [Migrating a v3 alpha app manifest](./upgrading.md#migrating-a-v3-alpha-app-manifest). | |||
| 57 | + | ||||
| 57 | ## 2. Migrate domain secrets | 58 | ## 2. Migrate domain secrets | ||
| 58 | 59 | ||||
| 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 | + | Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values: |
| 60 | 61 | ||||
| 61 | ```bash | 62 | ```bash | ||
| 62 | gordon apps apply --file ./example-app.toml --remote production | 63 | gordon apps apply --file ./example-app.toml --remote production | ||
M docs/reference/docker-labels.md +2 -2
| @@ -28,11 +28,11 @@ Containers created before v3 may carry these labels. They are read-only provenan | |||
| 28 | 28 | ||
| 29 | 29 | ### Backup Metadata | |
| 30 | 30 | ||
| 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. | |
| 31 | + | Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[services.<name>.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars. | |
| 32 | 32 | ||
| 33 | 33 | ## Image Labels | |
| 34 | 34 | ||
| 35 | - | No image-label inference exists in v3: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[service.readiness]`, `[[service.http]]`), not from image labels. | |
| 35 | + | No image-label inference exists in v3: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[services.<name>.readiness]`, `[[services.<name>.http]]`), not from image labels. | |
| 36 | 36 | ||
| 37 | 37 | ## Container Naming | |
| 38 | 38 | ||
| @@ -28,11 +28,11 @@ Containers created before v3 may carry these labels. They are read-only provenan | |||||
| 28 | 28 | ||||
| 29 | ### Backup Metadata | 29 | ### Backup Metadata | ||
| 30 | 30 | ||||
| 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. | 31 | + | Backups are declarative rather than label-driven. App manifests declare database and volume targets in `[services.<name>.backup]`; Gordon does not define or write Docker labels to enable backups, select database types or schedules, or identify backup sidecars. |
| 32 | 32 | ||||
| 33 | ## Image Labels | 33 | ## Image Labels | ||
| 34 | 34 | ||||
| 35 | - | No image-label inference exists in v3: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[service.readiness]`, `[[service.http]]`), not from image labels. | 35 | + | No image-label inference exists in v3: Gordon never creates routes, deploys, or resolves image names from Dockerfile labels. Push with a domain-like name or a `gordon.domain` label is treated as an ordinary image name. Readiness and proxy ports come from the app manifest (`[services.<name>.readiness]`, `[[services.<name>.http]]`), not from image labels. |
| 36 | 36 | ||||
| 37 | ## Container Naming | 37 | ## Container Naming | ||
| 38 | 38 | ||||
M docs/upgrading.md +36 -2
| @@ -10,16 +10,50 @@ Follow [Migrate to Gordon v3](./migrate-to-v3.md) for the complete cutover proce | |||
| 10 | 10 | ||
| 11 | 11 | ### Removed | |
| 12 | 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. | |
| 13 | + | - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. | |
| 14 | + | - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md). | |
| 14 | 15 | - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`. | |
| 15 | 16 | - 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 | 17 | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | |
| 17 | 18 | ||
| 19 | + | ### Migrating a v3 alpha app manifest | |
| 20 | + | ||
| 21 | + | Early v3 alpha manifests used the array-of-tables form `[[service]]` with a `name` field and `[service.*]` children. That shape is rejected with a keyed-schema diagnostic, not converted. Rewrite each service as a `[services.<name>]` table. | |
| 22 | + | ||
| 23 | + | Before (alpha, rejected): | |
| 24 | + | ||
| 25 | + | ```toml | |
| 26 | + | name = "blog" | |
| 27 | + | ||
| 28 | + | [[service]] | |
| 29 | + | name = "web" | |
| 30 | + | image = "registry.example.com/blog/web:1.2.3" | |
| 31 | + | ||
| 32 | + | [[service.http]] | |
| 33 | + | host = "blog.example.com" | |
| 34 | + | port = 8080 | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | After (current keyed schema): | |
| 38 | + | ||
| 39 | + | ```toml | |
| 40 | + | name = "blog" | |
| 41 | + | ||
| 42 | + | [services.web] | |
| 43 | + | image = "registry.example.com/blog/web:1.2.3" | |
| 44 | + | ||
| 45 | + | [[services.web.http]] | |
| 46 | + | host = "blog.example.com" | |
| 47 | + | port = 8080 | |
| 48 | + | ``` | |
| 49 | + | ||
| 50 | + | Names containing dots must be quoted: `[services."web.api"]` and `[[services."web.api".http]]`. See [App Manifest](./config/apps.md). | |
| 51 | + | ||
| 18 | 52 | ### Manual migration | |
| 19 | 53 | ||
| 20 | 54 | 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 | 55 | 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. | |
| 56 | + | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations. | |
| 23 | 57 | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | |
| 24 | 58 | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | |
| 25 | 59 | 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. | |
| @@ -10,16 +10,50 @@ Follow [Migrate to Gordon v3](./migrate-to-v3.md) for the complete cutover proce | |||||
| 10 | 10 | ||||
| 11 | ### Removed | 11 | ### Removed | ||
| 12 | 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. | 13 | + | - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. |
| 14 | + | - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md). | |||
| 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 | - 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 | - 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 | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | ||
| 17 | 18 | ||||
| 19 | + | ### Migrating a v3 alpha app manifest | |||
| 20 | + | ||||
| 21 | + | Early v3 alpha manifests used the array-of-tables form `[[service]]` with a `name` field and `[service.*]` children. That shape is rejected with a keyed-schema diagnostic, not converted. Rewrite each service as a `[services.<name>]` table. | |||
| 22 | + | ||||
| 23 | + | Before (alpha, rejected): | |||
| 24 | + | ||||
| 25 | + | ```toml | |||
| 26 | + | name = "blog" | |||
| 27 | + | ||||
| 28 | + | [[service]] | |||
| 29 | + | name = "web" | |||
| 30 | + | image = "registry.example.com/blog/web:1.2.3" | |||
| 31 | + | ||||
| 32 | + | [[service.http]] | |||
| 33 | + | host = "blog.example.com" | |||
| 34 | + | port = 8080 | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | After (current keyed schema): | |||
| 38 | + | ||||
| 39 | + | ```toml | |||
| 40 | + | name = "blog" | |||
| 41 | + | ||||
| 42 | + | [services.web] | |||
| 43 | + | image = "registry.example.com/blog/web:1.2.3" | |||
| 44 | + | ||||
| 45 | + | [[services.web.http]] | |||
| 46 | + | host = "blog.example.com" | |||
| 47 | + | port = 8080 | |||
| 48 | + | ``` | |||
| 49 | + | ||||
| 50 | + | Names containing dots must be quoted: `[services."web.api"]` and `[[services."web.api".http]]`. See [App Manifest](./config/apps.md). | |||
| 51 | + | ||||
| 18 | ### Manual migration | 52 | ### Manual migration | ||
| 19 | 53 | ||||
| 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. | 54 | 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). | 55 | 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. | 56 | + | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations. |
| 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 [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | 57 | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | ||
| 24 | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | 58 | 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. | 59 | 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. | ||
M gordon.toml.example +8 -7
| @@ -112,7 +112,7 @@ syslog_identifier = "gordon-access" | |||
| 112 | 112 | # ADMINISTRATIVE APP BIND MOUNTS | |
| 113 | 113 | # ============================================================================= | |
| 114 | 114 | # A named policy is the only way an app manifest may reference a host path. | |
| 115 | - | # The manifest declares [[service.bind]] name = "<mount>"; direct host paths | |
| 115 | + | # The manifest declares [[services.<name>.bind]] name = "<mount>"; direct host paths | |
| 116 | 116 | # are always rejected. allowed_apps/allowed_services are exact and non-empty, | |
| 117 | 117 | # and read_only/readonly force read-only: either side wins. | |
| 118 | 118 | # [app_mounts.app-logs] | |
| @@ -186,7 +186,7 @@ prefix = "" | |||
| 186 | 186 | [backups.volumes.retention] | |
| 187 | 187 | keep = 14 | |
| 188 | 188 | ||
| 189 | - | [routes] | |
| 189 | + | # [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed. | |
| 190 | 190 | # "app.example.com" = { image = "myapp:latest" } | |
| 191 | 191 | ||
| 192 | 192 | [external_routes] | |
| @@ -199,18 +199,19 @@ keep = 14 | |||
| 199 | 199 | # one file per app, applied with `gordon apps apply --file <app>.toml` | |
| 200 | 200 | # and activated with `gordon apps deploy <app>`. | |
| 201 | 201 | # | |
| 202 | - | # REMOVED: [routes] -> [[service.http]] in an app file | |
| 203 | - | # REMOVED: [attachments] -> [[service]] + volumes in an app file | |
| 202 | + | # REMOVED: [routes] -> [[services.<name>.http]] in an app file | |
| 203 | + | # REMOVED: [attachments] -> [services.<name>] + volumes in an app file | |
| 204 | 204 | # REMOVED: [network_groups] -> [[network.shared]] in an app file | |
| 205 | - | # REMOVED: [[services]]-as-app -> one [[service]] per app file | |
| 206 | - | # REMOVED: [service_routes] -> [[service.http]] in an app file | |
| 205 | + | # REMOVED: app-like [[services]] -> one [services.<name>] table per app file | |
| 206 | + | # (installation [[services]] below stays valid for L4 workloads) | |
| 207 | + | # REMOVED: [service_routes] -> [[services.<name>.http]] in an app file | |
| 207 | 208 | # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file | |
| 208 | 209 | # REMOVED: [previews] -> staging is an ordinary app file | |
| 209 | 210 | ||
| 210 | 211 | # Gordon-managed standalone L4 services. Use service:<name>:<port> from | |
| 211 | 212 | # traffic routers to expose published loopback ports. Standalone services | |
| 212 | 213 | # are installation-level: they live in gordon.toml, not in app files. | |
| 213 | - | # (App services are declared with [[service]] in <app>.toml files and | |
| 214 | + | # (App services are declared with [services.<name>] tables in <app>.toml files and | |
| 214 | 215 | # managed with `gordon apps`; see docs/cli/apps.md.) | |
| 215 | 216 | # [[services]] | |
| 216 | 217 | # name = "rust" | |
| @@ -112,7 +112,7 @@ syslog_identifier = "gordon-access" | |||||
| 112 | # ADMINISTRATIVE APP BIND MOUNTS | 112 | # ADMINISTRATIVE APP BIND MOUNTS | ||
| 113 | # ============================================================================= | 113 | # ============================================================================= | ||
| 114 | # A named policy is the only way an app manifest may reference a host path. | 114 | # A named policy is the only way an app manifest may reference a host path. | ||
| 115 | - | # The manifest declares [[service.bind]] name = "<mount>"; direct host paths | 115 | + | # The manifest declares [[services.<name>.bind]] name = "<mount>"; direct host paths |
| 116 | # are always rejected. allowed_apps/allowed_services are exact and non-empty, | 116 | # are always rejected. allowed_apps/allowed_services are exact and non-empty, | ||
| 117 | # and read_only/readonly force read-only: either side wins. | 117 | # and read_only/readonly force read-only: either side wins. | ||
| 118 | # [app_mounts.app-logs] | 118 | # [app_mounts.app-logs] | ||
| @@ -186,7 +186,7 @@ prefix = "" | |||||
| 186 | [backups.volumes.retention] | 186 | [backups.volumes.retention] | ||
| 187 | keep = 14 | 187 | keep = 14 | ||
| 188 | 188 | ||||
| 189 | - | [routes] | 189 | + | # [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed. |
| 190 | # "app.example.com" = { image = "myapp:latest" } | 190 | # "app.example.com" = { image = "myapp:latest" } | ||
| 191 | 191 | ||||
| 192 | [external_routes] | 192 | [external_routes] | ||
| @@ -199,18 +199,19 @@ keep = 14 | |||||
| 199 | # one file per app, applied with `gordon apps apply --file <app>.toml` | 199 | # one file per app, applied with `gordon apps apply --file <app>.toml` | ||
| 200 | # and activated with `gordon apps deploy <app>`. | 200 | # and activated with `gordon apps deploy <app>`. | ||
| 201 | # | 201 | # | ||
| 202 | - | # REMOVED: [routes] -> [[service.http]] in an app file | 202 | + | # REMOVED: [routes] -> [[services.<name>.http]] in an app file |
| 203 | - | # REMOVED: [attachments] -> [[service]] + volumes in an app file | 203 | + | # REMOVED: [attachments] -> [services.<name>] + volumes in an app file |
| 204 | # REMOVED: [network_groups] -> [[network.shared]] in an app file | 204 | # REMOVED: [network_groups] -> [[network.shared]] in an app file | ||
| 205 | - | # REMOVED: [[services]]-as-app -> one [[service]] per app file | 205 | + | # REMOVED: app-like [[services]] -> one [services.<name>] table per app file |
| 206 | - | # REMOVED: [service_routes] -> [[service.http]] in an app file | 206 | + | # (installation [[services]] below stays valid for L4 workloads) |
| 207 | + | # REMOVED: [service_routes] -> [[services.<name>.http]] in an app file | |||
| 207 | # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file | 208 | # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file | ||
| 208 | # REMOVED: [previews] -> staging is an ordinary app file | 209 | # REMOVED: [previews] -> staging is an ordinary app file | ||
| 209 | 210 | ||||
| 210 | # Gordon-managed standalone L4 services. Use service:<name>:<port> from | 211 | # Gordon-managed standalone L4 services. Use service:<name>:<port> from | ||
| 211 | # traffic routers to expose published loopback ports. Standalone services | 212 | # traffic routers to expose published loopback ports. Standalone services | ||
| 212 | # are installation-level: they live in gordon.toml, not in app files. | 213 | # are installation-level: they live in gordon.toml, not in app files. | ||
| 213 | - | # (App services are declared with [[service]] in <app>.toml files and | 214 | + | # (App services are declared with [services.<name>] tables in <app>.toml files and |
| 214 | # managed with `gordon apps`; see docs/cli/apps.md.) | 215 | # managed with `gordon apps`; see docs/cli/apps.md.) | ||
| 215 | # [[services]] | 216 | # [[services]] | ||
| 216 | # name = "rust" | 217 | # name = "rust" | ||
M internal/adapters/in/appmanifest/parse.go +95 -23
| @@ -9,6 +9,9 @@ import ( | |||
| 9 | 9 | "bytes" | |
| 10 | 10 | "errors" | |
| 11 | 11 | "fmt" | |
| 12 | + | "maps" | |
| 13 | + | "slices" | |
| 14 | + | "strconv" | |
| 12 | 15 | "strings" | |
| 13 | 16 | "time" | |
| 14 | 17 | ||
| @@ -18,11 +21,12 @@ import ( | |||
| 18 | 21 | ) | |
| 19 | 22 | ||
| 20 | 23 | // rawManifest mirrors the frozen TOML schema for strict decoding. | |
| 24 | + | // Services is keyed by service name: [services.<name>]. | |
| 21 | 25 | type rawManifest struct { | |
| 22 | - | Name string `toml:"name"` | |
| 23 | - | Env map[string]string `toml:"env"` | |
| 24 | - | Services []rawService `toml:"service"` | |
| 25 | - | Network rawNetwork `toml:"network"` | |
| 26 | + | Name string `toml:"name"` | |
| 27 | + | Env map[string]string `toml:"env"` | |
| 28 | + | Services map[string]rawService `toml:"services"` | |
| 29 | + | Network rawNetwork `toml:"network"` | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 28 | 32 | // rawNetwork mirrors [network] and its [[network.shared]] children. | |
| @@ -30,9 +34,9 @@ type rawNetwork struct { | |||
| 30 | 34 | Shared []rawSharedNetwork `toml:"shared"` | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 33 | - | // rawService mirrors one [[service]] table. | |
| 37 | + | // rawService mirrors one [services.<name>] table. The service name is | |
| 38 | + | // the table key, so there is no name field inside the table. | |
| 34 | 39 | type rawService struct { | |
| 35 | - | Name string `toml:"name"` | |
| 36 | 40 | Image string `toml:"image"` | |
| 37 | 41 | Command []string `toml:"command"` | |
| 38 | 42 | StopGrace string `toml:"stop_grace"` | |
| @@ -50,7 +54,7 @@ type rawService struct { | |||
| 50 | 54 | Backup rawBackup `toml:"backup"` | |
| 51 | 55 | } | |
| 52 | 56 | ||
| 53 | - | // rawReadiness mirrors [service.readiness]. | |
| 57 | + | // rawReadiness mirrors [services.<name>.readiness]. | |
| 54 | 58 | type rawReadiness struct { | |
| 55 | 59 | Type string `toml:"type"` | |
| 56 | 60 | Path string `toml:"path"` | |
| @@ -59,7 +63,7 @@ type rawReadiness struct { | |||
| 59 | 63 | Timeout string `toml:"timeout"` | |
| 60 | 64 | } | |
| 61 | 65 | ||
| 62 | - | // rawHTTP mirrors [[service.http]]. | |
| 66 | + | // rawHTTP mirrors [[services.<name>.http]]. | |
| 63 | 67 | type rawHTTP struct { | |
| 64 | 68 | Host string `toml:"host"` | |
| 65 | 69 | Port int `toml:"port"` | |
| @@ -70,42 +74,42 @@ type rawHTTP struct { | |||
| 70 | 74 | Visibility string `toml:"visibility"` | |
| 71 | 75 | } | |
| 72 | 76 | ||
| 73 | - | // rawTCP mirrors [[service.tcp]]. | |
| 77 | + | // rawTCP mirrors [[services.<name>.tcp]]. | |
| 74 | 78 | type rawTCP struct { | |
| 75 | 79 | Entrypoint string `toml:"entrypoint"` | |
| 76 | 80 | Port int `toml:"port"` | |
| 77 | 81 | Publish string `toml:"publish"` | |
| 78 | 82 | } | |
| 79 | 83 | ||
| 80 | - | // rawUDP mirrors [[service.udp]]. | |
| 84 | + | // rawUDP mirrors [[services.<name>.udp]]. | |
| 81 | 85 | type rawUDP struct { | |
| 82 | 86 | Entrypoint string `toml:"entrypoint"` | |
| 83 | 87 | Port int `toml:"port"` | |
| 84 | 88 | Publish string `toml:"publish"` | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 87 | - | // rawVolume mirrors [[service.volume]]. | |
| 91 | + | // rawVolume mirrors [[services.<name>.volume]]. | |
| 88 | 92 | type rawVolume struct { | |
| 89 | 93 | Name string `toml:"name"` | |
| 90 | 94 | Path string `toml:"path"` | |
| 91 | 95 | ReadOnly bool `toml:"readonly"` | |
| 92 | 96 | } | |
| 93 | 97 | ||
| 94 | - | // rawBind mirrors [[service.bind]]. | |
| 98 | + | // rawBind mirrors [[services.<name>.bind]]. | |
| 95 | 99 | type rawBind struct { | |
| 96 | 100 | Name string `toml:"name"` | |
| 97 | 101 | Path string `toml:"path"` | |
| 98 | 102 | ReadOnly bool `toml:"readonly"` | |
| 99 | 103 | } | |
| 100 | 104 | ||
| 101 | - | // rawDatabase mirrors [[service.database]]. | |
| 105 | + | // rawDatabase mirrors [[services.<name>.database]]. | |
| 102 | 106 | type rawDatabase struct { | |
| 103 | 107 | Name string `toml:"name"` | |
| 104 | 108 | Type string `toml:"type"` | |
| 105 | 109 | Schedule string `toml:"schedule"` | |
| 106 | 110 | } | |
| 107 | 111 | ||
| 108 | - | // rawBackup mirrors [service.backup]. | |
| 112 | + | // rawBackup mirrors [services.<name>.backup]. | |
| 109 | 113 | type rawBackup struct { | |
| 110 | 114 | Postgres []string `toml:"postgres"` | |
| 111 | 115 | Volume []string `toml:"volume"` | |
| @@ -124,6 +128,9 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) { | |||
| 124 | 128 | var raw rawManifest | |
| 125 | 129 | decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields() | |
| 126 | 130 | if err := decoder.Decode(&raw); err != nil { | |
| 131 | + | if hint := legacyKeyedSchemaHint(data); hint != "" { | |
| 132 | + | return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, hint) | |
| 133 | + | } | |
| 127 | 134 | return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err)) | |
| 128 | 135 | } | |
| 129 | 136 | spec, err := toDomain(raw) | |
| @@ -158,6 +165,43 @@ func formatDecodeError(err error) string { | |||
| 158 | 165 | return "unknown TOML fields or tables: " + strings.Join(unknown, ", ") | |
| 159 | 166 | } | |
| 160 | 167 | ||
| 168 | + | // legacyKeyedSchemaHint recognizes the retired array-of-tables app shapes | |
| 169 | + | // ([[service]] and [[services]]) and returns a stable, actionable | |
| 170 | + | // diagnostic. It inspects the loosely decoded document rather than | |
| 171 | + | // matching go-toml error wording, which is not a stable contract. | |
| 172 | + | func legacyKeyedSchemaHint(data []byte) string { | |
| 173 | + | var doc map[string]any | |
| 174 | + | if err := toml.Unmarshal(data, &doc); err != nil { | |
| 175 | + | return "" | |
| 176 | + | } | |
| 177 | + | const fix = "array-of-tables is a retired app shape; use one [services.<name>] table per service (keyed schema), e.g. [services.web]" | |
| 178 | + | switch { | |
| 179 | + | case isArrayTable(doc["service"]): | |
| 180 | + | return "[[service]] " + fix | |
| 181 | + | case isArrayTable(doc["services"]): | |
| 182 | + | return "[[services]] " + fix | |
| 183 | + | default: | |
| 184 | + | return "" | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // isArrayTable reports whether a loosely decoded value is the [[name]] | |
| 189 | + | // TOML shape: a non-empty array whose elements are all tables. Scalar | |
| 190 | + | // values, string arrays, and keyed tables are not array tables, so they | |
| 191 | + | // keep the plain unknown-field diagnostic. | |
| 192 | + | func isArrayTable(v any) bool { | |
| 193 | + | tables, ok := v.([]any) | |
| 194 | + | if !ok || len(tables) == 0 { | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | for _, table := range tables { | |
| 198 | + | if _, ok := table.(map[string]any); !ok { | |
| 199 | + | return false | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | ||
| 161 | 205 | // toDomain maps raw TOML onto domain types with normalization and defaults. | |
| 162 | 206 | func toDomain(raw rawManifest) (domain.AppSpec, error) { | |
| 163 | 207 | spec := domain.AppSpec{ | |
| @@ -169,8 +213,11 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||
| 169 | 213 | for key, value := range raw.Env { | |
| 170 | 214 | spec.Env[key] = value | |
| 171 | 215 | } | |
| 172 | - | for i := range raw.Services { | |
| 173 | - | svc, err := toDomainService(raw.Services[i]) | |
| 216 | + | // Iterate the service map in sorted key order so that domain | |
| 217 | + | // conversion and every downstream projection are deterministic | |
| 218 | + | // regardless of TOML declaration order. | |
| 219 | + | for _, name := range slices.Sorted(maps.Keys(raw.Services)) { | |
| 220 | + | svc, err := toDomainService(name, raw.Services[name]) | |
| 174 | 221 | if err != nil { | |
| 175 | 222 | return domain.AppSpec{}, err | |
| 176 | 223 | } | |
| @@ -205,10 +252,11 @@ func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) { | |||
| 205 | 252 | }, nil | |
| 206 | 253 | } | |
| 207 | 254 | ||
| 208 | - | // toDomainService maps one raw service with defaults. | |
| 209 | - | func toDomainService(raw rawService) (domain.AppService, error) { | |
| 255 | + | // toDomainService maps one raw service with defaults. serviceName is the | |
| 256 | + | // [services.<name>] table key and becomes the service identity. | |
| 257 | + | func toDomainService(serviceName string, raw rawService) (domain.AppService, error) { | |
| 210 | 258 | svc := domain.AppService{ | |
| 211 | - | Name: raw.Name, | |
| 259 | + | Name: serviceName, | |
| 212 | 260 | Image: normalizeImage(raw.Image), | |
| 213 | 261 | Command: append([]string(nil), raw.Command...), | |
| 214 | 262 | Secrets: map[string]string{}, | |
| @@ -221,18 +269,18 @@ func toDomainService(raw rawService) (domain.AppService, error) { | |||
| 221 | 269 | if raw.StopGrace != "" { | |
| 222 | 270 | parsed, err := time.ParseDuration(raw.StopGrace) | |
| 223 | 271 | if err != nil { | |
| 224 | - | return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, raw.Name, raw.StopGrace, err) | |
| 272 | + | return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, serviceName, raw.StopGrace, err) | |
| 225 | 273 | } | |
| 226 | 274 | stopGrace = parsed | |
| 227 | 275 | } | |
| 228 | 276 | svc.StopGrace = stopGrace | |
| 229 | - | readiness, err := toDomainReadiness(raw.Name, raw.Readiness) | |
| 277 | + | readiness, err := toDomainReadiness(serviceName, raw.Readiness) | |
| 230 | 278 | if err != nil { | |
| 231 | 279 | return domain.AppService{}, err | |
| 232 | 280 | } | |
| 233 | 281 | svc.Readiness = readiness | |
| 234 | 282 | for _, h := range raw.HTTP { | |
| 235 | - | iface, err := toDomainHTTP(raw.Name, h) | |
| 283 | + | iface, err := toDomainHTTP(serviceName, h) | |
| 236 | 284 | if err != nil { | |
| 237 | 285 | return domain.AppService{}, err | |
| 238 | 286 | } | |
| @@ -280,7 +328,7 @@ func toDomainService(raw rawService) (domain.AppService, error) { | |||
| 280 | 328 | }) | |
| 281 | 329 | } | |
| 282 | 330 | if len(raw.Env) > 0 { | |
| 283 | - | return domain.AppService{}, fmt.Errorf("%w: service %q [service.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, raw.Name) | |
| 331 | + | return domain.AppService{}, fmt.Errorf("%w: service %q [services.%s.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, serviceName, tomlKey(serviceName)) | |
| 284 | 332 | } | |
| 285 | 333 | return svc, nil | |
| 286 | 334 | } | |
| @@ -307,6 +355,30 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e | |||
| 307 | 355 | return readiness, nil | |
| 308 | 356 | } | |
| 309 | 357 | ||
| 358 | + | // tomlKey renders a service name as a TOML key, quoting it when it is | |
| 359 | + | // not a bare key (service names may contain dots, e.g. web.api). | |
| 360 | + | func tomlKey(name string) string { | |
| 361 | + | if isBareTOMLKey(name) { | |
| 362 | + | return name | |
| 363 | + | } | |
| 364 | + | return strconv.Quote(name) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // isBareTOMLKey reports whether name is a TOML bare key (A-Za-z0-9_-). | |
| 368 | + | func isBareTOMLKey(name string) bool { | |
| 369 | + | if name == "" { | |
| 370 | + | return false | |
| 371 | + | } | |
| 372 | + | for _, r := range name { | |
| 373 | + | switch { | |
| 374 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': | |
| 375 | + | default: | |
| 376 | + | return false | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | return true | |
| 380 | + | } | |
| 381 | + | ||
| 310 | 382 | // normalizeImage records a missing tag as explicit :latest. | |
| 311 | 383 | func normalizeImage(image string) string { | |
| 312 | 384 | image = strings.TrimSpace(image) | |
| @@ -9,6 +9,9 @@ import ( | |||||
| 9 | "bytes" | 9 | "bytes" | ||
| 10 | "errors" | 10 | "errors" | ||
| 11 | "fmt" | 11 | "fmt" | ||
| 12 | + | "maps" | |||
| 13 | + | "slices" | |||
| 14 | + | "strconv" | |||
| 12 | "strings" | 15 | "strings" | ||
| 13 | "time" | 16 | "time" | ||
| 14 | 17 | ||||
| @@ -18,11 +21,12 @@ import ( | |||||
| 18 | ) | 21 | ) | ||
| 19 | 22 | ||||
| 20 | // rawManifest mirrors the frozen TOML schema for strict decoding. | 23 | // rawManifest mirrors the frozen TOML schema for strict decoding. | ||
| 24 | + | // Services is keyed by service name: [services.<name>]. | |||
| 21 | type rawManifest struct { | 25 | type rawManifest struct { | ||
| 22 | - | Name string `toml:"name"` | 26 | + | Name string `toml:"name"` |
| 23 | - | Env map[string]string `toml:"env"` | 27 | + | Env map[string]string `toml:"env"` |
| 24 | - | Services []rawService `toml:"service"` | 28 | + | Services map[string]rawService `toml:"services"` |
| 25 | - | Network rawNetwork `toml:"network"` | 29 | + | Network rawNetwork `toml:"network"` |
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| 28 | // rawNetwork mirrors [network] and its [[network.shared]] children. | 32 | // rawNetwork mirrors [network] and its [[network.shared]] children. | ||
| @@ -30,9 +34,9 @@ type rawNetwork struct { | |||||
| 30 | Shared []rawSharedNetwork `toml:"shared"` | 34 | Shared []rawSharedNetwork `toml:"shared"` | ||
| 31 | } | 35 | } | ||
| 32 | 36 | ||||
| 33 | - | // rawService mirrors one [[service]] table. | 37 | + | // rawService mirrors one [services.<name>] table. The service name is |
| 38 | + | // the table key, so there is no name field inside the table. | |||
| 34 | type rawService struct { | 39 | type rawService struct { | ||
| 35 | - | Name string `toml:"name"` | |||
| 36 | Image string `toml:"image"` | 40 | Image string `toml:"image"` | ||
| 37 | Command []string `toml:"command"` | 41 | Command []string `toml:"command"` | ||
| 38 | StopGrace string `toml:"stop_grace"` | 42 | StopGrace string `toml:"stop_grace"` | ||
| @@ -50,7 +54,7 @@ type rawService struct { | |||||
| 50 | Backup rawBackup `toml:"backup"` | 54 | Backup rawBackup `toml:"backup"` | ||
| 51 | } | 55 | } | ||
| 52 | 56 | ||||
| 53 | - | // rawReadiness mirrors [service.readiness]. | 57 | + | // rawReadiness mirrors [services.<name>.readiness]. |
| 54 | type rawReadiness struct { | 58 | type rawReadiness struct { | ||
| 55 | Type string `toml:"type"` | 59 | Type string `toml:"type"` | ||
| 56 | Path string `toml:"path"` | 60 | Path string `toml:"path"` | ||
| @@ -59,7 +63,7 @@ type rawReadiness struct { | |||||
| 59 | Timeout string `toml:"timeout"` | 63 | Timeout string `toml:"timeout"` | ||
| 60 | } | 64 | } | ||
| 61 | 65 | ||||
| 62 | - | // rawHTTP mirrors [[service.http]]. | 66 | + | // rawHTTP mirrors [[services.<name>.http]]. |
| 63 | type rawHTTP struct { | 67 | type rawHTTP struct { | ||
| 64 | Host string `toml:"host"` | 68 | Host string `toml:"host"` | ||
| 65 | Port int `toml:"port"` | 69 | Port int `toml:"port"` | ||
| @@ -70,42 +74,42 @@ type rawHTTP struct { | |||||
| 70 | Visibility string `toml:"visibility"` | 74 | Visibility string `toml:"visibility"` | ||
| 71 | } | 75 | } | ||
| 72 | 76 | ||||
| 73 | - | // rawTCP mirrors [[service.tcp]]. | 77 | + | // rawTCP mirrors [[services.<name>.tcp]]. |
| 74 | type rawTCP struct { | 78 | type rawTCP struct { | ||
| 75 | Entrypoint string `toml:"entrypoint"` | 79 | Entrypoint string `toml:"entrypoint"` | ||
| 76 | Port int `toml:"port"` | 80 | Port int `toml:"port"` | ||
| 77 | Publish string `toml:"publish"` | 81 | Publish string `toml:"publish"` | ||
| 78 | } | 82 | } | ||
| 79 | 83 | ||||
| 80 | - | // rawUDP mirrors [[service.udp]]. | 84 | + | // rawUDP mirrors [[services.<name>.udp]]. |
| 81 | type rawUDP struct { | 85 | type rawUDP struct { | ||
| 82 | Entrypoint string `toml:"entrypoint"` | 86 | Entrypoint string `toml:"entrypoint"` | ||
| 83 | Port int `toml:"port"` | 87 | Port int `toml:"port"` | ||
| 84 | Publish string `toml:"publish"` | 88 | Publish string `toml:"publish"` | ||
| 85 | } | 89 | } | ||
| 86 | 90 | ||||
| 87 | - | // rawVolume mirrors [[service.volume]]. | 91 | + | // rawVolume mirrors [[services.<name>.volume]]. |
| 88 | type rawVolume struct { | 92 | type rawVolume struct { | ||
| 89 | Name string `toml:"name"` | 93 | Name string `toml:"name"` | ||
| 90 | Path string `toml:"path"` | 94 | Path string `toml:"path"` | ||
| 91 | ReadOnly bool `toml:"readonly"` | 95 | ReadOnly bool `toml:"readonly"` | ||
| 92 | } | 96 | } | ||
| 93 | 97 | ||||
| 94 | - | // rawBind mirrors [[service.bind]]. | 98 | + | // rawBind mirrors [[services.<name>.bind]]. |
| 95 | type rawBind struct { | 99 | type rawBind struct { | ||
| 96 | Name string `toml:"name"` | 100 | Name string `toml:"name"` | ||
| 97 | Path string `toml:"path"` | 101 | Path string `toml:"path"` | ||
| 98 | ReadOnly bool `toml:"readonly"` | 102 | ReadOnly bool `toml:"readonly"` | ||
| 99 | } | 103 | } | ||
| 100 | 104 | ||||
| 101 | - | // rawDatabase mirrors [[service.database]]. | 105 | + | // rawDatabase mirrors [[services.<name>.database]]. |
| 102 | type rawDatabase struct { | 106 | type rawDatabase struct { | ||
| 103 | Name string `toml:"name"` | 107 | Name string `toml:"name"` | ||
| 104 | Type string `toml:"type"` | 108 | Type string `toml:"type"` | ||
| 105 | Schedule string `toml:"schedule"` | 109 | Schedule string `toml:"schedule"` | ||
| 106 | } | 110 | } | ||
| 107 | 111 | ||||
| 108 | - | // rawBackup mirrors [service.backup]. | 112 | + | // rawBackup mirrors [services.<name>.backup]. |
| 109 | type rawBackup struct { | 113 | type rawBackup struct { | ||
| 110 | Postgres []string `toml:"postgres"` | 114 | Postgres []string `toml:"postgres"` | ||
| 111 | Volume []string `toml:"volume"` | 115 | Volume []string `toml:"volume"` | ||
| @@ -124,6 +128,9 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) { | |||||
| 124 | var raw rawManifest | 128 | var raw rawManifest | ||
| 125 | decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields() | 129 | decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields() | ||
| 126 | if err := decoder.Decode(&raw); err != nil { | 130 | if err := decoder.Decode(&raw); err != nil { | ||
| 131 | + | if hint := legacyKeyedSchemaHint(data); hint != "" { | |||
| 132 | + | return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, hint) | |||
| 133 | + | } | |||
| 127 | return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err)) | 134 | return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err)) | ||
| 128 | } | 135 | } | ||
| 129 | spec, err := toDomain(raw) | 136 | spec, err := toDomain(raw) | ||
| @@ -158,6 +165,43 @@ func formatDecodeError(err error) string { | |||||
| 158 | return "unknown TOML fields or tables: " + strings.Join(unknown, ", ") | 165 | return "unknown TOML fields or tables: " + strings.Join(unknown, ", ") | ||
| 159 | } | 166 | } | ||
| 160 | 167 | ||||
| 168 | + | // legacyKeyedSchemaHint recognizes the retired array-of-tables app shapes | |||
| 169 | + | // ([[service]] and [[services]]) and returns a stable, actionable | |||
| 170 | + | // diagnostic. It inspects the loosely decoded document rather than | |||
| 171 | + | // matching go-toml error wording, which is not a stable contract. | |||
| 172 | + | func legacyKeyedSchemaHint(data []byte) string { | |||
| 173 | + | var doc map[string]any | |||
| 174 | + | if err := toml.Unmarshal(data, &doc); err != nil { | |||
| 175 | + | return "" | |||
| 176 | + | } | |||
| 177 | + | const fix = "array-of-tables is a retired app shape; use one [services.<name>] table per service (keyed schema), e.g. [services.web]" | |||
| 178 | + | switch { | |||
| 179 | + | case isArrayTable(doc["service"]): | |||
| 180 | + | return "[[service]] " + fix | |||
| 181 | + | case isArrayTable(doc["services"]): | |||
| 182 | + | return "[[services]] " + fix | |||
| 183 | + | default: | |||
| 184 | + | return "" | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // isArrayTable reports whether a loosely decoded value is the [[name]] | |||
| 189 | + | // TOML shape: a non-empty array whose elements are all tables. Scalar | |||
| 190 | + | // values, string arrays, and keyed tables are not array tables, so they | |||
| 191 | + | // keep the plain unknown-field diagnostic. | |||
| 192 | + | func isArrayTable(v any) bool { | |||
| 193 | + | tables, ok := v.([]any) | |||
| 194 | + | if !ok || len(tables) == 0 { | |||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | for _, table := range tables { | |||
| 198 | + | if _, ok := table.(map[string]any); !ok { | |||
| 199 | + | return false | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | return true | |||
| 203 | + | } | |||
| 204 | + | ||||
| 161 | // toDomain maps raw TOML onto domain types with normalization and defaults. | 205 | // toDomain maps raw TOML onto domain types with normalization and defaults. | ||
| 162 | func toDomain(raw rawManifest) (domain.AppSpec, error) { | 206 | func toDomain(raw rawManifest) (domain.AppSpec, error) { | ||
| 163 | spec := domain.AppSpec{ | 207 | spec := domain.AppSpec{ | ||
| @@ -169,8 +213,11 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||||
| 169 | for key, value := range raw.Env { | 213 | for key, value := range raw.Env { | ||
| 170 | spec.Env[key] = value | 214 | spec.Env[key] = value | ||
| 171 | } | 215 | } | ||
| 172 | - | for i := range raw.Services { | 216 | + | // Iterate the service map in sorted key order so that domain |
| 173 | - | svc, err := toDomainService(raw.Services[i]) | 217 | + | // conversion and every downstream projection are deterministic |
| 218 | + | // regardless of TOML declaration order. | |||
| 219 | + | for _, name := range slices.Sorted(maps.Keys(raw.Services)) { | |||
| 220 | + | svc, err := toDomainService(name, raw.Services[name]) | |||
| 174 | if err != nil { | 221 | if err != nil { | ||
| 175 | return domain.AppSpec{}, err | 222 | return domain.AppSpec{}, err | ||
| 176 | } | 223 | } | ||
| @@ -205,10 +252,11 @@ func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) { | |||||
| 205 | }, nil | 252 | }, nil | ||
| 206 | } | 253 | } | ||
| 207 | 254 | ||||
| 208 | - | // toDomainService maps one raw service with defaults. | 255 | + | // toDomainService maps one raw service with defaults. serviceName is the |
| 209 | - | func toDomainService(raw rawService) (domain.AppService, error) { | 256 | + | // [services.<name>] table key and becomes the service identity. |
| 257 | + | func toDomainService(serviceName string, raw rawService) (domain.AppService, error) { | |||
| 210 | svc := domain.AppService{ | 258 | svc := domain.AppService{ | ||
| 211 | - | Name: raw.Name, | 259 | + | Name: serviceName, |
| 212 | Image: normalizeImage(raw.Image), | 260 | Image: normalizeImage(raw.Image), | ||
| 213 | Command: append([]string(nil), raw.Command...), | 261 | Command: append([]string(nil), raw.Command...), | ||
| 214 | Secrets: map[string]string{}, | 262 | Secrets: map[string]string{}, | ||
| @@ -221,18 +269,18 @@ func toDomainService(raw rawService) (domain.AppService, error) { | |||||
| 221 | if raw.StopGrace != "" { | 269 | if raw.StopGrace != "" { | ||
| 222 | parsed, err := time.ParseDuration(raw.StopGrace) | 270 | parsed, err := time.ParseDuration(raw.StopGrace) | ||
| 223 | if err != nil { | 271 | if err != nil { | ||
| 224 | - | return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, raw.Name, raw.StopGrace, err) | 272 | + | return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, serviceName, raw.StopGrace, err) |
| 225 | } | 273 | } | ||
| 226 | stopGrace = parsed | 274 | stopGrace = parsed | ||
| 227 | } | 275 | } | ||
| 228 | svc.StopGrace = stopGrace | 276 | svc.StopGrace = stopGrace | ||
| 229 | - | readiness, err := toDomainReadiness(raw.Name, raw.Readiness) | 277 | + | readiness, err := toDomainReadiness(serviceName, raw.Readiness) |
| 230 | if err != nil { | 278 | if err != nil { | ||
| 231 | return domain.AppService{}, err | 279 | return domain.AppService{}, err | ||
| 232 | } | 280 | } | ||
| 233 | svc.Readiness = readiness | 281 | svc.Readiness = readiness | ||
| 234 | for _, h := range raw.HTTP { | 282 | for _, h := range raw.HTTP { | ||
| 235 | - | iface, err := toDomainHTTP(raw.Name, h) | 283 | + | iface, err := toDomainHTTP(serviceName, h) |
| 236 | if err != nil { | 284 | if err != nil { | ||
| 237 | return domain.AppService{}, err | 285 | return domain.AppService{}, err | ||
| 238 | } | 286 | } | ||
| @@ -280,7 +328,7 @@ func toDomainService(raw rawService) (domain.AppService, error) { | |||||
| 280 | }) | 328 | }) | ||
| 281 | } | 329 | } | ||
| 282 | if len(raw.Env) > 0 { | 330 | if len(raw.Env) > 0 { | ||
| 283 | - | return domain.AppService{}, fmt.Errorf("%w: service %q [service.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, raw.Name) | 331 | + | return domain.AppService{}, fmt.Errorf("%w: service %q [services.%s.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, serviceName, tomlKey(serviceName)) |
| 284 | } | 332 | } | ||
| 285 | return svc, nil | 333 | return svc, nil | ||
| 286 | } | 334 | } | ||
| @@ -307,6 +355,30 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e | |||||
| 307 | return readiness, nil | 355 | return readiness, nil | ||
| 308 | } | 356 | } | ||
| 309 | 357 | ||||
| 358 | + | // tomlKey renders a service name as a TOML key, quoting it when it is | |||
| 359 | + | // not a bare key (service names may contain dots, e.g. web.api). | |||
| 360 | + | func tomlKey(name string) string { | |||
| 361 | + | if isBareTOMLKey(name) { | |||
| 362 | + | return name | |||
| 363 | + | } | |||
| 364 | + | return strconv.Quote(name) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // isBareTOMLKey reports whether name is a TOML bare key (A-Za-z0-9_-). | |||
| 368 | + | func isBareTOMLKey(name string) bool { | |||
| 369 | + | if name == "" { | |||
| 370 | + | return false | |||
| 371 | + | } | |||
| 372 | + | for _, r := range name { | |||
| 373 | + | switch { | |||
| 374 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': | |||
| 375 | + | default: | |||
| 376 | + | return false | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | return true | |||
| 380 | + | } | |||
| 381 | + | ||||
| 310 | // normalizeImage records a missing tag as explicit :latest. | 382 | // normalizeImage records a missing tag as explicit :latest. | ||
| 311 | func normalizeImage(image string) string { | 383 | func normalizeImage(image string) string { | ||
| 312 | image = strings.TrimSpace(image) | 384 | image = strings.TrimSpace(image) | ||
M internal/adapters/in/appmanifest/parse_test.go +271 -73
| @@ -17,19 +17,18 @@ name = "blog" | |||
| 17 | 17 | [env] | |
| 18 | 18 | APP_ENV = "production" | |
| 19 | 19 | ||
| 20 | - | [[service]] | |
| 21 | - | name = "web" | |
| 20 | + | [services.web] | |
| 22 | 21 | image = "registry.example.com/blog/web:1.4.2" | |
| 23 | 22 | ||
| 24 | - | [service.readiness] | |
| 23 | + | [services.web.readiness] | |
| 25 | 24 | type = "http" | |
| 26 | 25 | path = "/healthz" | |
| 27 | 26 | ||
| 28 | - | [[service.http]] | |
| 27 | + | [[services.web.http]] | |
| 29 | 28 | host = "Blog.Example.COM." | |
| 30 | 29 | port = 8080 | |
| 31 | 30 | ||
| 32 | - | [service.secrets] | |
| 31 | + | [services.web.secrets] | |
| 33 | 32 | DATABASE_URL = "database-url" | |
| 34 | 33 | ` | |
| 35 | 34 | ||
| @@ -41,6 +40,7 @@ func TestParse_ValidWeb(t *testing.T) { | |||
| 41 | 40 | assert.Equal(t, "production", spec.Env["APP_ENV"]) | |
| 42 | 41 | require.Len(t, spec.Services, 1) | |
| 43 | 42 | svc := spec.Services[0] | |
| 43 | + | assert.Equal(t, "web", svc.Name) | |
| 44 | 44 | assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image) | |
| 45 | 45 | assert.Equal(t, "blog.example.com", svc.HTTP[0].Host) | |
| 46 | 46 | assert.Equal(t, "auto", svc.HTTP[0].TLS) | |
| @@ -50,8 +50,7 @@ func TestParse_ValidWeb(t *testing.T) { | |||
| 50 | 50 | func TestParse_SharedNetwork(t *testing.T) { | |
| 51 | 51 | doc := ` | |
| 52 | 52 | name = "media" | |
| 53 | - | [[service]] | |
| 54 | - | name = "web" | |
| 53 | + | [services.web] | |
| 55 | 54 | image = "registry.example.com/media/web:1" | |
| 56 | 55 | [[network.shared]] | |
| 57 | 56 | network = "backend" | |
| @@ -78,10 +77,9 @@ func TestParse_FileNameMismatchIsWarning(t *testing.T) { | |||
| 78 | 77 | func TestParse_MissingTagNormalizesToLatest(t *testing.T) { | |
| 79 | 78 | doc := ` | |
| 80 | 79 | name = "blog" | |
| 81 | - | [[service]] | |
| 82 | - | name = "web" | |
| 80 | + | [services.web] | |
| 83 | 81 | image = "registry.example.com/blog/web" | |
| 84 | - | [[service.http]] | |
| 82 | + | [[services.web.http]] | |
| 85 | 83 | host = "blog.example.com" | |
| 86 | 84 | port = 8080 | |
| 87 | 85 | ` | |
| @@ -95,30 +93,240 @@ func TestParse_UnknownFieldsRejected(t *testing.T) { | |||
| 95 | 93 | name string | |
| 96 | 94 | doc string | |
| 97 | 95 | }{ | |
| 98 | - | {"top level", "name = \"blog\"\nbogus = 1\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n"}, | |
| 99 | - | {"service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nbogus = 1\n"}, | |
| 100 | - | {"readiness", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.readiness]\nbogus = 1\n"}, | |
| 101 | - | {"http", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"}, | |
| 96 | + | {"top level", "name = \"blog\"\nbogus = 1\n[services.web]\nimage = \"img:1\"\n"}, | |
| 97 | + | {"service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nbogus = 1\n"}, | |
| 98 | + | {"readiness", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.readiness]\nbogus = 1\n"}, | |
| 99 | + | {"http", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"}, | |
| 102 | 100 | } | |
| 103 | 101 | for _, tc := range cases { | |
| 104 | 102 | t.Run(tc.name, func(t *testing.T) { | |
| 105 | 103 | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 106 | 104 | require.Error(t, err) | |
| 105 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 107 | 106 | }) | |
| 108 | 107 | } | |
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | - | func TestParse_ServiceEnvRejected(t *testing.T) { | |
| 110 | + | // TestParse_OldSyntaxRejected proves the retired array-of-tables schema | |
| 111 | + | // and its name field are no longer accepted, with no compatibility shim. | |
| 112 | + | func TestParse_OldSyntaxRejected(t *testing.T) { | |
| 113 | + | cases := []struct { | |
| 114 | + | name string | |
| 115 | + | doc string | |
| 116 | + | wantErr string | |
| 117 | + | }{ | |
| 118 | + | { | |
| 119 | + | "legacy service array", | |
| 120 | + | "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", | |
| 121 | + | "[[service]] array-of-tables is a retired app shape", | |
| 122 | + | }, | |
| 123 | + | { | |
| 124 | + | "legacy plural service array", | |
| 125 | + | "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n", | |
| 126 | + | "[[services]] array-of-tables is a retired app shape", | |
| 127 | + | }, | |
| 128 | + | { | |
| 129 | + | "service name as field", | |
| 130 | + | "name = \"blog\"\n[services.web]\nname = \"web\"\nimage = \"img:1\"\n", | |
| 131 | + | "unknown TOML fields or tables: services.web.name", | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | for _, tc := range cases { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 137 | + | require.Error(t, err) | |
| 138 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 139 | + | assert.ErrorContains(t, err, tc.wantErr) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // TestParse_RetiredArraySyntaxHintIsActionable proves the rejected | |
| 145 | + | // [[service]] and [[services]] shapes carry the same stable keyed-schema | |
| 146 | + | // remedy instead of leaking go-toml decoding wording. | |
| 147 | + | func TestParse_RetiredArraySyntaxHintIsActionable(t *testing.T) { | |
| 148 | + | docs := map[string]string{ | |
| 149 | + | "singular": "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", | |
| 150 | + | "plural": "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n", | |
| 151 | + | } | |
| 152 | + | for name, doc := range docs { | |
| 153 | + | t.Run(name, func(t *testing.T) { | |
| 154 | + | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 155 | + | require.Error(t, err) | |
| 156 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 157 | + | assert.ErrorContains(t, err, "keyed schema") | |
| 158 | + | assert.ErrorContains(t, err, "[services.<name>]") | |
| 159 | + | assert.ErrorContains(t, err, "[services.web]") | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // TestParse_NonArrayTablesKeepPlainDiagnostic proves the keyed-schema hint | |
| 165 | + | // stays reserved for real array-of-tables shapes: a [service] table, a | |
| 166 | + | // scalar service value, and a string array under services are not array | |
| 167 | + | // tables, so they keep the plain unknown-field diagnostic instead of | |
| 168 | + | // being described as [[service]]/[[services]]. | |
| 169 | + | func TestParse_NonArrayTablesKeepPlainDiagnostic(t *testing.T) { | |
| 170 | + | cases := []struct { | |
| 171 | + | name string | |
| 172 | + | doc string | |
| 173 | + | wantErr string | |
| 174 | + | }{ | |
| 175 | + | { | |
| 176 | + | "service table", | |
| 177 | + | "name = \"blog\"\n[service]\nimage = \"img:1\"\n", | |
| 178 | + | "unknown TOML fields or tables: service", | |
| 179 | + | }, | |
| 180 | + | { | |
| 181 | + | "service scalar", | |
| 182 | + | "name = \"blog\"\nservice = \"web\"\n", | |
| 183 | + | "unknown TOML fields or tables: service", | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | // services is a keyed table, so an array value fails to decode. | |
| 187 | + | // Assert only that the diagnostic names the key, never go-toml's | |
| 188 | + | // internal wording. | |
| 189 | + | "services string array", | |
| 190 | + | "name = \"blog\"\nservices = [\"web\"]\n", | |
| 191 | + | "services", | |
| 192 | + | }, | |
| 193 | + | } | |
| 194 | + | for _, tc := range cases { | |
| 195 | + | t.Run(tc.name, func(t *testing.T) { | |
| 196 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 197 | + | require.Error(t, err) | |
| 198 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 199 | + | assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.wantErr)) | |
| 200 | + | assert.NotContains(t, err.Error(), "array-of-tables is a retired app shape") | |
| 201 | + | }) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // TestParse_UnknownNestedFieldRejected proves strict decoding still | |
| 206 | + | // reports the dotted path of an unknown table or field under a service. | |
| 207 | + | func TestParse_UnknownNestedFieldRejected(t *testing.T) { | |
| 208 | + | cases := []struct { | |
| 209 | + | name string | |
| 210 | + | doc string | |
| 211 | + | wantErr string | |
| 212 | + | }{ | |
| 213 | + | { | |
| 214 | + | "unknown protocol table", | |
| 215 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.rcon]]\nport = 28016\n", | |
| 216 | + | "unknown TOML fields or tables: services.web.rcon", | |
| 217 | + | }, | |
| 218 | + | { | |
| 219 | + | "unknown nested table", | |
| 220 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.extra]\nkey = \"value\"\n", | |
| 221 | + | "unknown TOML fields or tables: services.web.extra", | |
| 222 | + | }, | |
| 223 | + | } | |
| 224 | + | for _, tc := range cases { | |
| 225 | + | t.Run(tc.name, func(t *testing.T) { | |
| 226 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 227 | + | require.Error(t, err) | |
| 228 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 229 | + | assert.ErrorContains(t, err, tc.wantErr) | |
| 230 | + | }) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // TestParse_QuotedDottedServiceKey proves a service name containing a | |
| 235 | + | // dot must be quoted, decodes as one key, and is usable by nested tables. | |
| 236 | + | func TestParse_QuotedDottedServiceKey(t *testing.T) { | |
| 112 | 237 | doc := ` | |
| 113 | 238 | name = "blog" | |
| 114 | - | [[service]] | |
| 115 | - | name = "web" | |
| 239 | + | [services."web.api"] | |
| 240 | + | image = "registry.example.com/blog/api:1" | |
| 241 | + | [[services."web.api".http]] | |
| 242 | + | host = "api.example.com" | |
| 243 | + | port = 8080 | |
| 244 | + | ` | |
| 245 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | require.Len(t, spec.Services, 1) | |
| 248 | + | assert.Equal(t, "web.api", spec.Services[0].Name) | |
| 249 | + | require.Len(t, spec.Services[0].HTTP, 1) | |
| 250 | + | assert.Equal(t, "api.example.com", spec.Services[0].HTTP[0].Host) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // TestParse_UnquotedDottedKeyRejected proves an unquoted dotted key is | |
| 254 | + | // read as nested tables, not as one service name, and is rejected. | |
| 255 | + | func TestParse_UnquotedDottedKeyRejected(t *testing.T) { | |
| 256 | + | doc := ` | |
| 257 | + | name = "blog" | |
| 258 | + | [services.web.api] | |
| 259 | + | image = "registry.example.com/blog/api:1" | |
| 260 | + | ` | |
| 261 | + | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 262 | + | require.Error(t, err) | |
| 263 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 264 | + | assert.ErrorContains(t, err, "unknown TOML fields or tables: services.web.api") | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // TestParse_DuplicateServiceTableRejected proves the TOML decoder rejects | |
| 268 | + | // a repeated [services.<name>] table before domain validation runs. | |
| 269 | + | func TestParse_DuplicateServiceTableRejected(t *testing.T) { | |
| 270 | + | doc := ` | |
| 271 | + | name = "blog" | |
| 272 | + | [services.web] | |
| 116 | 273 | image = "img:1" | |
| 117 | - | [service.env] | |
| 118 | - | FOO = "bar" | |
| 274 | + | [services.web] | |
| 275 | + | image = "img:2" | |
| 119 | 276 | ` | |
| 120 | 277 | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 121 | - | require.ErrorContains(t, err, "[service.env]") | |
| 278 | + | require.Error(t, err) | |
| 279 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 280 | + | assert.ErrorContains(t, err, "already exists") | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // TestParse_ServiceOrderDeterministic proves map keys are sorted before | |
| 284 | + | // domain conversion so spec.Services order never depends on TOML order. | |
| 285 | + | func TestParse_ServiceOrderDeterministic(t *testing.T) { | |
| 286 | + | doc := ` | |
| 287 | + | name = "blog" | |
| 288 | + | [services.zeta] | |
| 289 | + | image = "img:1" | |
| 290 | + | [services.alpha] | |
| 291 | + | image = "img:2" | |
| 292 | + | [services.mid] | |
| 293 | + | image = "img:3" | |
| 294 | + | ` | |
| 295 | + | for i := 0; i < 3; i++ { | |
| 296 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | require.Len(t, spec.Services, 3) | |
| 299 | + | assert.Equal(t, []string{"alpha", "mid", "zeta"}, []string{ | |
| 300 | + | spec.Services[0].Name, spec.Services[1].Name, spec.Services[2].Name, | |
| 301 | + | }) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func TestParse_ServiceEnvRejected(t *testing.T) { | |
| 306 | + | cases := []struct { | |
| 307 | + | name string | |
| 308 | + | doc string | |
| 309 | + | wantErr string | |
| 310 | + | }{ | |
| 311 | + | { | |
| 312 | + | "bare service name", | |
| 313 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n", | |
| 314 | + | "[services.web.env]", | |
| 315 | + | }, | |
| 316 | + | { | |
| 317 | + | "dotted service name is quoted", | |
| 318 | + | "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n", | |
| 319 | + | `[services."web.api".env]`, | |
| 320 | + | }, | |
| 321 | + | } | |
| 322 | + | for _, tc := range cases { | |
| 323 | + | t.Run(tc.name, func(t *testing.T) { | |
| 324 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |
| 325 | + | require.Error(t, err) | |
| 326 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 327 | + | require.ErrorContains(t, err, tc.wantErr) | |
| 328 | + | }) | |
| 329 | + | } | |
| 122 | 330 | } | |
| 123 | 331 | ||
| 124 | 332 | func TestParse_Names(t *testing.T) { | |
| @@ -127,18 +335,18 @@ func TestParse_Names(t *testing.T) { | |||
| 127 | 335 | doc string | |
| 128 | 336 | wantErr string | |
| 129 | 337 | }{ | |
| 130 | - | {"bad app", "name = \"Blog!\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "app name"}, | |
| 131 | - | {"double dash app", "name = \"a--b\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "--"}, | |
| 132 | - | {"reserved app", "name = \"gordon\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "reserved"}, | |
| 338 | + | {"bad app", "name = \"Blog!\"\n[services.web]\nimage = \"img:1\"\n", "app name"}, | |
| 339 | + | {"double dash app", "name = \"a--b\"\n[services.web]\nimage = \"img:1\"\n", "--"}, | |
| 340 | + | {"reserved app", "name = \"gordon\"\n[services.web]\nimage = \"img:1\"\n", "reserved"}, | |
| 133 | 341 | {"no services", "name = \"blog\"\n", "at least one service"}, | |
| 134 | - | {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"}, | |
| 135 | - | {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"}, | |
| 136 | - | {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"}, | |
| 137 | - | {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: service.replicas"}, | |
| 138 | - | {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"}, | |
| 139 | - | {"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"}, | |
| 140 | - | {"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"}, | |
| 141 | - | {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "secret references"}, | |
| 342 | + | {"dup service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web]\nimage = \"img:2\"\n", "already exists"}, | |
| 343 | + | {"dup service case", "name = \"blog\"\n[services.Web]\nimage = \"img:1\"\n", "must match"}, | |
| 344 | + | {"normalized collision", "name = \"blog\"\n[services.\"a.b\"]\nimage = \"img:1\"\n[services.\"a-b\"]\nimage = \"img:2\"\n", "same runtime identifier"}, | |
| 345 | + | {"replicas rejected", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: services.web.replicas"}, | |
| 346 | + | {"volume double dash", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"}, | |
| 347 | + | {"shared volume", "name = \"blog\"\n[services.a]\nimage = \"img:1\"\n[[services.a.volume]]\nname = \"d\"\npath = \"/data\"\n[services.b]\nimage = \"img:1\"\n[[services.b.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"}, | |
| 348 | + | {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[services.web]\nimage = \"img:1\"\n[services.web.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"}, | |
| 349 | + | {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[services.web]\nimage = \"img:1\"\n", "secret references"}, | |
| 142 | 350 | } | |
| 143 | 351 | for _, tc := range cases { | |
| 144 | 352 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -154,13 +362,13 @@ func TestParse_Readiness(t *testing.T) { | |||
| 154 | 362 | doc string | |
| 155 | 363 | wantErr string | |
| 156 | 364 | }{ | |
| 157 | - | {"bad type", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"}, | |
| 158 | - | {"log needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"}, | |
| 159 | - | {"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"}, | |
| 160 | - | {"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"}, | |
| 161 | - | {"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"}, | |
| 162 | - | {"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"}, | |
| 163 | - | {"bad timeout", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntimeout = \"99h\"\n", "timeout must be within"}, | |
| 365 | + | {"bad type", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"}, | |
| 366 | + | {"log needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"}, | |
| 367 | + | {"http needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"http\"\n", "http readiness requires path"}, | |
| 368 | + | {"udp tcp rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "UDP-only"}, | |
| 369 | + | {"multi tcp needs port", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "readiness.port is required"}, | |
| 370 | + | {"bad port ref", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"}, | |
| 371 | + | {"bad timeout", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntimeout = \"99h\"\n", "timeout must be within"}, | |
| 164 | 372 | } | |
| 165 | 373 | for _, tc := range cases { | |
| 166 | 374 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -176,10 +384,10 @@ func TestParse_Interfaces(t *testing.T) { | |||
| 176 | 384 | doc string | |
| 177 | 385 | wantErr string | |
| 178 | 386 | }{ | |
| 179 | - | {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"}, | |
| 180 | - | {"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"}, | |
| 181 | - | {"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", "unknown TOML fields or tables: service.rcon"}, | |
| 182 | - | {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"}, | |
| 387 | + | {"tcp needs entrypoint", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"}, | |
| 388 | + | {"publish hostname", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"}, | |
| 389 | + | {"rcon table rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "unknown TOML fields or tables: services.w.rcon"}, | |
| 390 | + | {"bad host", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"}, | |
| 183 | 391 | } | |
| 184 | 392 | for _, tc := range cases { | |
| 185 | 393 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -192,17 +400,16 @@ func TestParse_Interfaces(t *testing.T) { | |||
| 192 | 400 | func TestParse_Backups(t *testing.T) { | |
| 193 | 401 | doc := ` | |
| 194 | 402 | name = "shop" | |
| 195 | - | [[service]] | |
| 196 | - | name = "api" | |
| 403 | + | [services.api] | |
| 197 | 404 | image = "registry.example.com/shop/api:2.0.0" | |
| 198 | - | [[service.http]] | |
| 405 | + | [[services.api.http]] | |
| 199 | 406 | host = "shop.example.com" | |
| 200 | 407 | port = 8080 | |
| 201 | - | [[service.database]] | |
| 408 | + | [[services.api.database]] | |
| 202 | 409 | name = "main" | |
| 203 | 410 | type = "postgres" | |
| 204 | 411 | schedule = "daily" | |
| 205 | - | [service.backup] | |
| 412 | + | [services.api.backup] | |
| 206 | 413 | postgres = ["main"] | |
| 207 | 414 | ` | |
| 208 | 415 | spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml") | |
| @@ -225,23 +432,22 @@ postgres = ["main"] | |||
| 225 | 432 | func TestParse_GameAndStagingExamples(t *testing.T) { | |
| 226 | 433 | game := ` | |
| 227 | 434 | name = "rust" | |
| 228 | - | [[service]] | |
| 229 | - | name = "server" | |
| 435 | + | [services.server] | |
| 230 | 436 | image = "registry.example.com/games/rust:2026.09" | |
| 231 | - | [service.readiness] | |
| 437 | + | [services.server.readiness] | |
| 232 | 438 | type = "log" | |
| 233 | 439 | path = "/data/logs/server.log" | |
| 234 | 440 | contains = "Server startup complete" | |
| 235 | 441 | timeout = "5m" | |
| 236 | - | [[service.udp]] | |
| 442 | + | [[services.server.udp]] | |
| 237 | 443 | entrypoint = "udp" | |
| 238 | 444 | port = 28015 | |
| 239 | 445 | publish = "0.0.0.0:28015" | |
| 240 | - | [[service.tcp]] | |
| 446 | + | [[services.server.tcp]] | |
| 241 | 447 | entrypoint = "tcp" | |
| 242 | 448 | port = 28016 | |
| 243 | 449 | publish = "127.0.0.1:28016" | |
| 244 | - | [[service.volume]] | |
| 450 | + | [[services.server.volume]] | |
| 245 | 451 | name = "rust-data" | |
| 246 | 452 | path = "/data" | |
| 247 | 453 | ` | |
| @@ -255,10 +461,9 @@ path = "/data" | |||
| 255 | 461 | name = "blog-staging" | |
| 256 | 462 | [env] | |
| 257 | 463 | APP_ENV = "staging" | |
| 258 | - | [[service]] | |
| 259 | - | name = "web" | |
| 464 | + | [services.web] | |
| 260 | 465 | image = "registry.example.com/blog/web:1.4.2-rc.1" | |
| 261 | - | [[service.http]] | |
| 466 | + | [[services.web.http]] | |
| 262 | 467 | host = "staging-blog.example.com" | |
| 263 | 468 | port = 8080 | |
| 264 | 469 | ` | |
| @@ -278,14 +483,13 @@ func TestParse_NoFileEnvSecretReads(t *testing.T) { | |||
| 278 | 483 | func TestParse_Binds(t *testing.T) { | |
| 279 | 484 | doc := ` | |
| 280 | 485 | name = "blog" | |
| 281 | - | [[service]] | |
| 282 | - | name = "web" | |
| 486 | + | [services.web] | |
| 283 | 487 | image = "registry.example.com/blog/web:1.4.2" | |
| 284 | - | [[service.bind]] | |
| 488 | + | [[services.web.bind]] | |
| 285 | 489 | name = "config" | |
| 286 | 490 | path = "/etc/app.conf" | |
| 287 | 491 | readonly = true | |
| 288 | - | [[service.bind]] | |
| 492 | + | [[services.web.bind]] | |
| 289 | 493 | name = "data" | |
| 290 | 494 | path = "/var/lib/app" | |
| 291 | 495 | ` | |
| @@ -299,10 +503,9 @@ path = "/var/lib/app" | |||
| 299 | 503 | func TestParse_BindDestinationRejected(t *testing.T) { | |
| 300 | 504 | doc := ` | |
| 301 | 505 | name = "blog" | |
| 302 | - | [[service]] | |
| 303 | - | name = "web" | |
| 506 | + | [services.web] | |
| 304 | 507 | image = "registry.example.com/blog/web:1.4.2" | |
| 305 | - | [[service.bind]] | |
| 508 | + | [[services.web.bind]] | |
| 306 | 509 | name = "dev" | |
| 307 | 510 | path = "/dev" | |
| 308 | 511 | ` | |
| @@ -314,8 +517,7 @@ path = "/dev" | |||
| 314 | 517 | func TestParse_Devices(t *testing.T) { | |
| 315 | 518 | doc := ` | |
| 316 | 519 | name = "demo" | |
| 317 | - | [[service]] | |
| 318 | - | name = "worker" | |
| 520 | + | [services.worker] | |
| 319 | 521 | image = "registry.example.com/demo/worker:1" | |
| 320 | 522 | devices = ["test_gpu"] | |
| 321 | 523 | ` | |
| @@ -328,8 +530,7 @@ devices = ["test_gpu"] | |||
| 328 | 530 | func TestParse_DevicesWrongType(t *testing.T) { | |
| 329 | 531 | doc := ` | |
| 330 | 532 | name = "demo" | |
| 331 | - | [[service]] | |
| 332 | - | name = "worker" | |
| 533 | + | [services.worker] | |
| 333 | 534 | image = "registry.example.com/demo/worker:1" | |
| 334 | 535 | devices = "test_gpu" | |
| 335 | 536 | ` | |
| @@ -340,8 +541,7 @@ devices = "test_gpu" | |||
| 340 | 541 | func TestParse_DevicesMalformedRejected(t *testing.T) { | |
| 341 | 542 | doc := ` | |
| 342 | 543 | name = "demo" | |
| 343 | - | [[service]] | |
| 344 | - | name = "worker" | |
| 544 | + | [services.worker] | |
| 345 | 545 | image = "registry.example.com/demo/worker:1" | |
| 346 | 546 | devices = ["Bad Name!"] | |
| 347 | 547 | ` | |
| @@ -352,8 +552,7 @@ devices = ["Bad Name!"] | |||
| 352 | 552 | func TestParse_DevicesDuplicateRejected(t *testing.T) { | |
| 353 | 553 | doc := ` | |
| 354 | 554 | name = "demo" | |
| 355 | - | [[service]] | |
| 356 | - | name = "worker" | |
| 555 | + | [services.worker] | |
| 357 | 556 | image = "registry.example.com/demo/worker:1" | |
| 358 | 557 | devices = ["test_gpu", "test_gpu"] | |
| 359 | 558 | ` | |
| @@ -365,8 +564,7 @@ devices = ["test_gpu", "test_gpu"] | |||
| 365 | 564 | func TestParse_UnknownGPUFieldRejected(t *testing.T) { | |
| 366 | 565 | doc := ` | |
| 367 | 566 | name = "demo" | |
| 368 | - | [[service]] | |
| 369 | - | name = "worker" | |
| 567 | + | [services.worker] | |
| 370 | 568 | image = "registry.example.com/demo/worker:1" | |
| 371 | 569 | gpus = "all" | |
| 372 | 570 | ` | |
| @@ -17,19 +17,18 @@ name = "blog" | |||||
| 17 | [env] | 17 | [env] | ||
| 18 | APP_ENV = "production" | 18 | APP_ENV = "production" | ||
| 19 | 19 | ||||
| 20 | - | [[service]] | 20 | + | [services.web] |
| 21 | - | name = "web" | |||
| 22 | image = "registry.example.com/blog/web:1.4.2" | 21 | image = "registry.example.com/blog/web:1.4.2" | ||
| 23 | 22 | ||||
| 24 | - | [service.readiness] | 23 | + | [services.web.readiness] |
| 25 | type = "http" | 24 | type = "http" | ||
| 26 | path = "/healthz" | 25 | path = "/healthz" | ||
| 27 | 26 | ||||
| 28 | - | [[service.http]] | 27 | + | [[services.web.http]] |
| 29 | host = "Blog.Example.COM." | 28 | host = "Blog.Example.COM." | ||
| 30 | port = 8080 | 29 | port = 8080 | ||
| 31 | 30 | ||||
| 32 | - | [service.secrets] | 31 | + | [services.web.secrets] |
| 33 | DATABASE_URL = "database-url" | 32 | DATABASE_URL = "database-url" | ||
| 34 | ` | 33 | ` | ||
| 35 | 34 | ||||
| @@ -41,6 +40,7 @@ func TestParse_ValidWeb(t *testing.T) { | |||||
| 41 | assert.Equal(t, "production", spec.Env["APP_ENV"]) | 40 | assert.Equal(t, "production", spec.Env["APP_ENV"]) | ||
| 42 | require.Len(t, spec.Services, 1) | 41 | require.Len(t, spec.Services, 1) | ||
| 43 | svc := spec.Services[0] | 42 | svc := spec.Services[0] | ||
| 43 | + | assert.Equal(t, "web", svc.Name) | |||
| 44 | assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image) | 44 | assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image) | ||
| 45 | assert.Equal(t, "blog.example.com", svc.HTTP[0].Host) | 45 | assert.Equal(t, "blog.example.com", svc.HTTP[0].Host) | ||
| 46 | assert.Equal(t, "auto", svc.HTTP[0].TLS) | 46 | assert.Equal(t, "auto", svc.HTTP[0].TLS) | ||
| @@ -50,8 +50,7 @@ func TestParse_ValidWeb(t *testing.T) { | |||||
| 50 | func TestParse_SharedNetwork(t *testing.T) { | 50 | func TestParse_SharedNetwork(t *testing.T) { | ||
| 51 | doc := ` | 51 | doc := ` | ||
| 52 | name = "media" | 52 | name = "media" | ||
| 53 | - | [[service]] | 53 | + | [services.web] |
| 54 | - | name = "web" | |||
| 55 | image = "registry.example.com/media/web:1" | 54 | image = "registry.example.com/media/web:1" | ||
| 56 | [[network.shared]] | 55 | [[network.shared]] | ||
| 57 | network = "backend" | 56 | network = "backend" | ||
| @@ -78,10 +77,9 @@ func TestParse_FileNameMismatchIsWarning(t *testing.T) { | |||||
| 78 | func TestParse_MissingTagNormalizesToLatest(t *testing.T) { | 77 | func TestParse_MissingTagNormalizesToLatest(t *testing.T) { | ||
| 79 | doc := ` | 78 | doc := ` | ||
| 80 | name = "blog" | 79 | name = "blog" | ||
| 81 | - | [[service]] | 80 | + | [services.web] |
| 82 | - | name = "web" | |||
| 83 | image = "registry.example.com/blog/web" | 81 | image = "registry.example.com/blog/web" | ||
| 84 | - | [[service.http]] | 82 | + | [[services.web.http]] |
| 85 | host = "blog.example.com" | 83 | host = "blog.example.com" | ||
| 86 | port = 8080 | 84 | port = 8080 | ||
| 87 | ` | 85 | ` | ||
| @@ -95,30 +93,240 @@ func TestParse_UnknownFieldsRejected(t *testing.T) { | |||||
| 95 | name string | 93 | name string | ||
| 96 | doc string | 94 | doc string | ||
| 97 | }{ | 95 | }{ | ||
| 98 | - | {"top level", "name = \"blog\"\nbogus = 1\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n"}, | 96 | + | {"top level", "name = \"blog\"\nbogus = 1\n[services.web]\nimage = \"img:1\"\n"}, |
| 99 | - | {"service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nbogus = 1\n"}, | 97 | + | {"service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nbogus = 1\n"}, |
| 100 | - | {"readiness", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.readiness]\nbogus = 1\n"}, | 98 | + | {"readiness", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.readiness]\nbogus = 1\n"}, |
| 101 | - | {"http", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"}, | 99 | + | {"http", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"}, |
| 102 | } | 100 | } | ||
| 103 | for _, tc := range cases { | 101 | for _, tc := range cases { | ||
| 104 | t.Run(tc.name, func(t *testing.T) { | 102 | t.Run(tc.name, func(t *testing.T) { | ||
| 105 | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | 103 | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | ||
| 106 | require.Error(t, err) | 104 | require.Error(t, err) | ||
| 105 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 107 | }) | 106 | }) | ||
| 108 | } | 107 | } | ||
| 109 | } | 108 | } | ||
| 110 | 109 | ||||
| 111 | - | func TestParse_ServiceEnvRejected(t *testing.T) { | 110 | + | // TestParse_OldSyntaxRejected proves the retired array-of-tables schema |
| 111 | + | // and its name field are no longer accepted, with no compatibility shim. | |||
| 112 | + | func TestParse_OldSyntaxRejected(t *testing.T) { | |||
| 113 | + | cases := []struct { | |||
| 114 | + | name string | |||
| 115 | + | doc string | |||
| 116 | + | wantErr string | |||
| 117 | + | }{ | |||
| 118 | + | { | |||
| 119 | + | "legacy service array", | |||
| 120 | + | "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", | |||
| 121 | + | "[[service]] array-of-tables is a retired app shape", | |||
| 122 | + | }, | |||
| 123 | + | { | |||
| 124 | + | "legacy plural service array", | |||
| 125 | + | "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n", | |||
| 126 | + | "[[services]] array-of-tables is a retired app shape", | |||
| 127 | + | }, | |||
| 128 | + | { | |||
| 129 | + | "service name as field", | |||
| 130 | + | "name = \"blog\"\n[services.web]\nname = \"web\"\nimage = \"img:1\"\n", | |||
| 131 | + | "unknown TOML fields or tables: services.web.name", | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | for _, tc := range cases { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 136 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |||
| 137 | + | require.Error(t, err) | |||
| 138 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 139 | + | assert.ErrorContains(t, err, tc.wantErr) | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // TestParse_RetiredArraySyntaxHintIsActionable proves the rejected | |||
| 145 | + | // [[service]] and [[services]] shapes carry the same stable keyed-schema | |||
| 146 | + | // remedy instead of leaking go-toml decoding wording. | |||
| 147 | + | func TestParse_RetiredArraySyntaxHintIsActionable(t *testing.T) { | |||
| 148 | + | docs := map[string]string{ | |||
| 149 | + | "singular": "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", | |||
| 150 | + | "plural": "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n", | |||
| 151 | + | } | |||
| 152 | + | for name, doc := range docs { | |||
| 153 | + | t.Run(name, func(t *testing.T) { | |||
| 154 | + | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |||
| 155 | + | require.Error(t, err) | |||
| 156 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 157 | + | assert.ErrorContains(t, err, "keyed schema") | |||
| 158 | + | assert.ErrorContains(t, err, "[services.<name>]") | |||
| 159 | + | assert.ErrorContains(t, err, "[services.web]") | |||
| 160 | + | }) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // TestParse_NonArrayTablesKeepPlainDiagnostic proves the keyed-schema hint | |||
| 165 | + | // stays reserved for real array-of-tables shapes: a [service] table, a | |||
| 166 | + | // scalar service value, and a string array under services are not array | |||
| 167 | + | // tables, so they keep the plain unknown-field diagnostic instead of | |||
| 168 | + | // being described as [[service]]/[[services]]. | |||
| 169 | + | func TestParse_NonArrayTablesKeepPlainDiagnostic(t *testing.T) { | |||
| 170 | + | cases := []struct { | |||
| 171 | + | name string | |||
| 172 | + | doc string | |||
| 173 | + | wantErr string | |||
| 174 | + | }{ | |||
| 175 | + | { | |||
| 176 | + | "service table", | |||
| 177 | + | "name = \"blog\"\n[service]\nimage = \"img:1\"\n", | |||
| 178 | + | "unknown TOML fields or tables: service", | |||
| 179 | + | }, | |||
| 180 | + | { | |||
| 181 | + | "service scalar", | |||
| 182 | + | "name = \"blog\"\nservice = \"web\"\n", | |||
| 183 | + | "unknown TOML fields or tables: service", | |||
| 184 | + | }, | |||
| 185 | + | { | |||
| 186 | + | // services is a keyed table, so an array value fails to decode. | |||
| 187 | + | // Assert only that the diagnostic names the key, never go-toml's | |||
| 188 | + | // internal wording. | |||
| 189 | + | "services string array", | |||
| 190 | + | "name = \"blog\"\nservices = [\"web\"]\n", | |||
| 191 | + | "services", | |||
| 192 | + | }, | |||
| 193 | + | } | |||
| 194 | + | for _, tc := range cases { | |||
| 195 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 196 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |||
| 197 | + | require.Error(t, err) | |||
| 198 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 199 | + | assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.wantErr)) | |||
| 200 | + | assert.NotContains(t, err.Error(), "array-of-tables is a retired app shape") | |||
| 201 | + | }) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // TestParse_UnknownNestedFieldRejected proves strict decoding still | |||
| 206 | + | // reports the dotted path of an unknown table or field under a service. | |||
| 207 | + | func TestParse_UnknownNestedFieldRejected(t *testing.T) { | |||
| 208 | + | cases := []struct { | |||
| 209 | + | name string | |||
| 210 | + | doc string | |||
| 211 | + | wantErr string | |||
| 212 | + | }{ | |||
| 213 | + | { | |||
| 214 | + | "unknown protocol table", | |||
| 215 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.rcon]]\nport = 28016\n", | |||
| 216 | + | "unknown TOML fields or tables: services.web.rcon", | |||
| 217 | + | }, | |||
| 218 | + | { | |||
| 219 | + | "unknown nested table", | |||
| 220 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.extra]\nkey = \"value\"\n", | |||
| 221 | + | "unknown TOML fields or tables: services.web.extra", | |||
| 222 | + | }, | |||
| 223 | + | } | |||
| 224 | + | for _, tc := range cases { | |||
| 225 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 226 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |||
| 227 | + | require.Error(t, err) | |||
| 228 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 229 | + | assert.ErrorContains(t, err, tc.wantErr) | |||
| 230 | + | }) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // TestParse_QuotedDottedServiceKey proves a service name containing a | |||
| 235 | + | // dot must be quoted, decodes as one key, and is usable by nested tables. | |||
| 236 | + | func TestParse_QuotedDottedServiceKey(t *testing.T) { | |||
| 112 | doc := ` | 237 | doc := ` | ||
| 113 | name = "blog" | 238 | name = "blog" | ||
| 114 | - | [[service]] | 239 | + | [services."web.api"] |
| 115 | - | name = "web" | 240 | + | image = "registry.example.com/blog/api:1" |
| 241 | + | [[services."web.api".http]] | |||
| 242 | + | host = "api.example.com" | |||
| 243 | + | port = 8080 | |||
| 244 | + | ` | |||
| 245 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |||
| 246 | + | require.NoError(t, err) | |||
| 247 | + | require.Len(t, spec.Services, 1) | |||
| 248 | + | assert.Equal(t, "web.api", spec.Services[0].Name) | |||
| 249 | + | require.Len(t, spec.Services[0].HTTP, 1) | |||
| 250 | + | assert.Equal(t, "api.example.com", spec.Services[0].HTTP[0].Host) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // TestParse_UnquotedDottedKeyRejected proves an unquoted dotted key is | |||
| 254 | + | // read as nested tables, not as one service name, and is rejected. | |||
| 255 | + | func TestParse_UnquotedDottedKeyRejected(t *testing.T) { | |||
| 256 | + | doc := ` | |||
| 257 | + | name = "blog" | |||
| 258 | + | [services.web.api] | |||
| 259 | + | image = "registry.example.com/blog/api:1" | |||
| 260 | + | ` | |||
| 261 | + | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |||
| 262 | + | require.Error(t, err) | |||
| 263 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 264 | + | assert.ErrorContains(t, err, "unknown TOML fields or tables: services.web.api") | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // TestParse_DuplicateServiceTableRejected proves the TOML decoder rejects | |||
| 268 | + | // a repeated [services.<name>] table before domain validation runs. | |||
| 269 | + | func TestParse_DuplicateServiceTableRejected(t *testing.T) { | |||
| 270 | + | doc := ` | |||
| 271 | + | name = "blog" | |||
| 272 | + | [services.web] | |||
| 116 | image = "img:1" | 273 | image = "img:1" | ||
| 117 | - | [service.env] | 274 | + | [services.web] |
| 118 | - | FOO = "bar" | 275 | + | image = "img:2" |
| 119 | ` | 276 | ` | ||
| 120 | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | 277 | _, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | ||
| 121 | - | require.ErrorContains(t, err, "[service.env]") | 278 | + | require.Error(t, err) |
| 279 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 280 | + | assert.ErrorContains(t, err, "already exists") | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // TestParse_ServiceOrderDeterministic proves map keys are sorted before | |||
| 284 | + | // domain conversion so spec.Services order never depends on TOML order. | |||
| 285 | + | func TestParse_ServiceOrderDeterministic(t *testing.T) { | |||
| 286 | + | doc := ` | |||
| 287 | + | name = "blog" | |||
| 288 | + | [services.zeta] | |||
| 289 | + | image = "img:1" | |||
| 290 | + | [services.alpha] | |||
| 291 | + | image = "img:2" | |||
| 292 | + | [services.mid] | |||
| 293 | + | image = "img:3" | |||
| 294 | + | ` | |||
| 295 | + | for i := 0; i < 3; i++ { | |||
| 296 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |||
| 297 | + | require.NoError(t, err) | |||
| 298 | + | require.Len(t, spec.Services, 3) | |||
| 299 | + | assert.Equal(t, []string{"alpha", "mid", "zeta"}, []string{ | |||
| 300 | + | spec.Services[0].Name, spec.Services[1].Name, spec.Services[2].Name, | |||
| 301 | + | }) | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func TestParse_ServiceEnvRejected(t *testing.T) { | |||
| 306 | + | cases := []struct { | |||
| 307 | + | name string | |||
| 308 | + | doc string | |||
| 309 | + | wantErr string | |||
| 310 | + | }{ | |||
| 311 | + | { | |||
| 312 | + | "bare service name", | |||
| 313 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n", | |||
| 314 | + | "[services.web.env]", | |||
| 315 | + | }, | |||
| 316 | + | { | |||
| 317 | + | "dotted service name is quoted", | |||
| 318 | + | "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n", | |||
| 319 | + | `[services."web.api".env]`, | |||
| 320 | + | }, | |||
| 321 | + | } | |||
| 322 | + | for _, tc := range cases { | |||
| 323 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 324 | + | _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml") | |||
| 325 | + | require.Error(t, err) | |||
| 326 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 327 | + | require.ErrorContains(t, err, tc.wantErr) | |||
| 328 | + | }) | |||
| 329 | + | } | |||
| 122 | } | 330 | } | ||
| 123 | 331 | ||||
| 124 | func TestParse_Names(t *testing.T) { | 332 | func TestParse_Names(t *testing.T) { | ||
| @@ -127,18 +335,18 @@ func TestParse_Names(t *testing.T) { | |||||
| 127 | doc string | 335 | doc string | ||
| 128 | wantErr string | 336 | wantErr string | ||
| 129 | }{ | 337 | }{ | ||
| 130 | - | {"bad app", "name = \"Blog!\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "app name"}, | 338 | + | {"bad app", "name = \"Blog!\"\n[services.web]\nimage = \"img:1\"\n", "app name"}, |
| 131 | - | {"double dash app", "name = \"a--b\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "--"}, | 339 | + | {"double dash app", "name = \"a--b\"\n[services.web]\nimage = \"img:1\"\n", "--"}, |
| 132 | - | {"reserved app", "name = \"gordon\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "reserved"}, | 340 | + | {"reserved app", "name = \"gordon\"\n[services.web]\nimage = \"img:1\"\n", "reserved"}, |
| 133 | {"no services", "name = \"blog\"\n", "at least one service"}, | 341 | {"no services", "name = \"blog\"\n", "at least one service"}, | ||
| 134 | - | {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"}, | 342 | + | {"dup service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web]\nimage = \"img:2\"\n", "already exists"}, |
| 135 | - | {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"}, | 343 | + | {"dup service case", "name = \"blog\"\n[services.Web]\nimage = \"img:1\"\n", "must match"}, |
| 136 | - | {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"}, | 344 | + | {"normalized collision", "name = \"blog\"\n[services.\"a.b\"]\nimage = \"img:1\"\n[services.\"a-b\"]\nimage = \"img:2\"\n", "same runtime identifier"}, |
| 137 | - | {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: service.replicas"}, | 345 | + | {"replicas rejected", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: services.web.replicas"}, |
| 138 | - | {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"}, | 346 | + | {"volume double dash", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"}, |
| 139 | - | {"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"}, | 347 | + | {"shared volume", "name = \"blog\"\n[services.a]\nimage = \"img:1\"\n[[services.a.volume]]\nname = \"d\"\npath = \"/data\"\n[services.b]\nimage = \"img:1\"\n[[services.b.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"}, |
| 140 | - | {"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"}, | 348 | + | {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[services.web]\nimage = \"img:1\"\n[services.web.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"}, |
| 141 | - | {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n", "secret references"}, | 349 | + | {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[services.web]\nimage = \"img:1\"\n", "secret references"}, |
| 142 | } | 350 | } | ||
| 143 | for _, tc := range cases { | 351 | for _, tc := range cases { | ||
| 144 | t.Run(tc.name, func(t *testing.T) { | 352 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -154,13 +362,13 @@ func TestParse_Readiness(t *testing.T) { | |||||
| 154 | doc string | 362 | doc string | ||
| 155 | wantErr string | 363 | wantErr string | ||
| 156 | }{ | 364 | }{ | ||
| 157 | - | {"bad type", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"}, | 365 | + | {"bad type", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"}, |
| 158 | - | {"log needs path", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"}, | 366 | + | {"log needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"}, |
| 159 | - | {"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"}, | 367 | + | {"http needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"http\"\n", "http readiness requires path"}, |
| 160 | - | {"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"}, | 368 | + | {"udp tcp rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "UDP-only"}, |
| 161 | - | {"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"}, | 369 | + | {"multi tcp needs port", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "readiness.port is required"}, |
| 162 | - | {"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"}, | 370 | + | {"bad port ref", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"}, |
| 163 | - | {"bad timeout", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[service.readiness]\ntimeout = \"99h\"\n", "timeout must be within"}, | 371 | + | {"bad timeout", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntimeout = \"99h\"\n", "timeout must be within"}, |
| 164 | } | 372 | } | ||
| 165 | for _, tc := range cases { | 373 | for _, tc := range cases { | ||
| 166 | t.Run(tc.name, func(t *testing.T) { | 374 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -176,10 +384,10 @@ func TestParse_Interfaces(t *testing.T) { | |||||
| 176 | doc string | 384 | doc string | ||
| 177 | wantErr string | 385 | wantErr string | ||
| 178 | }{ | 386 | }{ | ||
| 179 | - | {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"}, | 387 | + | {"tcp needs entrypoint", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"}, |
| 180 | - | {"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"}, | 388 | + | {"publish hostname", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"}, |
| 181 | - | {"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", "unknown TOML fields or tables: service.rcon"}, | 389 | + | {"rcon table rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "unknown TOML fields or tables: services.w.rcon"}, |
| 182 | - | {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"}, | 390 | + | {"bad host", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"}, |
| 183 | } | 391 | } | ||
| 184 | for _, tc := range cases { | 392 | for _, tc := range cases { | ||
| 185 | t.Run(tc.name, func(t *testing.T) { | 393 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -192,17 +400,16 @@ func TestParse_Interfaces(t *testing.T) { | |||||
| 192 | func TestParse_Backups(t *testing.T) { | 400 | func TestParse_Backups(t *testing.T) { | ||
| 193 | doc := ` | 401 | doc := ` | ||
| 194 | name = "shop" | 402 | name = "shop" | ||
| 195 | - | [[service]] | 403 | + | [services.api] |
| 196 | - | name = "api" | |||
| 197 | image = "registry.example.com/shop/api:2.0.0" | 404 | image = "registry.example.com/shop/api:2.0.0" | ||
| 198 | - | [[service.http]] | 405 | + | [[services.api.http]] |
| 199 | host = "shop.example.com" | 406 | host = "shop.example.com" | ||
| 200 | port = 8080 | 407 | port = 8080 | ||
| 201 | - | [[service.database]] | 408 | + | [[services.api.database]] |
| 202 | name = "main" | 409 | name = "main" | ||
| 203 | type = "postgres" | 410 | type = "postgres" | ||
| 204 | schedule = "daily" | 411 | schedule = "daily" | ||
| 205 | - | [service.backup] | 412 | + | [services.api.backup] |
| 206 | postgres = ["main"] | 413 | postgres = ["main"] | ||
| 207 | ` | 414 | ` | ||
| 208 | spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml") | 415 | spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml") | ||
| @@ -225,23 +432,22 @@ postgres = ["main"] | |||||
| 225 | func TestParse_GameAndStagingExamples(t *testing.T) { | 432 | func TestParse_GameAndStagingExamples(t *testing.T) { | ||
| 226 | game := ` | 433 | game := ` | ||
| 227 | name = "rust" | 434 | name = "rust" | ||
| 228 | - | [[service]] | 435 | + | [services.server] |
| 229 | - | name = "server" | |||
| 230 | image = "registry.example.com/games/rust:2026.09" | 436 | image = "registry.example.com/games/rust:2026.09" | ||
| 231 | - | [service.readiness] | 437 | + | [services.server.readiness] |
| 232 | type = "log" | 438 | type = "log" | ||
| 233 | path = "/data/logs/server.log" | 439 | path = "/data/logs/server.log" | ||
| 234 | contains = "Server startup complete" | 440 | contains = "Server startup complete" | ||
| 235 | timeout = "5m" | 441 | timeout = "5m" | ||
| 236 | - | [[service.udp]] | 442 | + | [[services.server.udp]] |
| 237 | entrypoint = "udp" | 443 | entrypoint = "udp" | ||
| 238 | port = 28015 | 444 | port = 28015 | ||
| 239 | publish = "0.0.0.0:28015" | 445 | publish = "0.0.0.0:28015" | ||
| 240 | - | [[service.tcp]] | 446 | + | [[services.server.tcp]] |
| 241 | entrypoint = "tcp" | 447 | entrypoint = "tcp" | ||
| 242 | port = 28016 | 448 | port = 28016 | ||
| 243 | publish = "127.0.0.1:28016" | 449 | publish = "127.0.0.1:28016" | ||
| 244 | - | [[service.volume]] | 450 | + | [[services.server.volume]] |
| 245 | name = "rust-data" | 451 | name = "rust-data" | ||
| 246 | path = "/data" | 452 | path = "/data" | ||
| 247 | ` | 453 | ` | ||
| @@ -255,10 +461,9 @@ path = "/data" | |||||
| 255 | name = "blog-staging" | 461 | name = "blog-staging" | ||
| 256 | [env] | 462 | [env] | ||
| 257 | APP_ENV = "staging" | 463 | APP_ENV = "staging" | ||
| 258 | - | [[service]] | 464 | + | [services.web] |
| 259 | - | name = "web" | |||
| 260 | image = "registry.example.com/blog/web:1.4.2-rc.1" | 465 | image = "registry.example.com/blog/web:1.4.2-rc.1" | ||
| 261 | - | [[service.http]] | 466 | + | [[services.web.http]] |
| 262 | host = "staging-blog.example.com" | 467 | host = "staging-blog.example.com" | ||
| 263 | port = 8080 | 468 | port = 8080 | ||
| 264 | ` | 469 | ` | ||
| @@ -278,14 +483,13 @@ func TestParse_NoFileEnvSecretReads(t *testing.T) { | |||||
| 278 | func TestParse_Binds(t *testing.T) { | 483 | func TestParse_Binds(t *testing.T) { | ||
| 279 | doc := ` | 484 | doc := ` | ||
| 280 | name = "blog" | 485 | name = "blog" | ||
| 281 | - | [[service]] | 486 | + | [services.web] |
| 282 | - | name = "web" | |||
| 283 | image = "registry.example.com/blog/web:1.4.2" | 487 | image = "registry.example.com/blog/web:1.4.2" | ||
| 284 | - | [[service.bind]] | 488 | + | [[services.web.bind]] |
| 285 | name = "config" | 489 | name = "config" | ||
| 286 | path = "/etc/app.conf" | 490 | path = "/etc/app.conf" | ||
| 287 | readonly = true | 491 | readonly = true | ||
| 288 | - | [[service.bind]] | 492 | + | [[services.web.bind]] |
| 289 | name = "data" | 493 | name = "data" | ||
| 290 | path = "/var/lib/app" | 494 | path = "/var/lib/app" | ||
| 291 | ` | 495 | ` | ||
| @@ -299,10 +503,9 @@ path = "/var/lib/app" | |||||
| 299 | func TestParse_BindDestinationRejected(t *testing.T) { | 503 | func TestParse_BindDestinationRejected(t *testing.T) { | ||
| 300 | doc := ` | 504 | doc := ` | ||
| 301 | name = "blog" | 505 | name = "blog" | ||
| 302 | - | [[service]] | 506 | + | [services.web] |
| 303 | - | name = "web" | |||
| 304 | image = "registry.example.com/blog/web:1.4.2" | 507 | image = "registry.example.com/blog/web:1.4.2" | ||
| 305 | - | [[service.bind]] | 508 | + | [[services.web.bind]] |
| 306 | name = "dev" | 509 | name = "dev" | ||
| 307 | path = "/dev" | 510 | path = "/dev" | ||
| 308 | ` | 511 | ` | ||
| @@ -314,8 +517,7 @@ path = "/dev" | |||||
| 314 | func TestParse_Devices(t *testing.T) { | 517 | func TestParse_Devices(t *testing.T) { | ||
| 315 | doc := ` | 518 | doc := ` | ||
| 316 | name = "demo" | 519 | name = "demo" | ||
| 317 | - | [[service]] | 520 | + | [services.worker] |
| 318 | - | name = "worker" | |||
| 319 | image = "registry.example.com/demo/worker:1" | 521 | image = "registry.example.com/demo/worker:1" | ||
| 320 | devices = ["test_gpu"] | 522 | devices = ["test_gpu"] | ||
| 321 | ` | 523 | ` | ||
| @@ -328,8 +530,7 @@ devices = ["test_gpu"] | |||||
| 328 | func TestParse_DevicesWrongType(t *testing.T) { | 530 | func TestParse_DevicesWrongType(t *testing.T) { | ||
| 329 | doc := ` | 531 | doc := ` | ||
| 330 | name = "demo" | 532 | name = "demo" | ||
| 331 | - | [[service]] | 533 | + | [services.worker] |
| 332 | - | name = "worker" | |||
| 333 | image = "registry.example.com/demo/worker:1" | 534 | image = "registry.example.com/demo/worker:1" | ||
| 334 | devices = "test_gpu" | 535 | devices = "test_gpu" | ||
| 335 | ` | 536 | ` | ||
| @@ -340,8 +541,7 @@ devices = "test_gpu" | |||||
| 340 | func TestParse_DevicesMalformedRejected(t *testing.T) { | 541 | func TestParse_DevicesMalformedRejected(t *testing.T) { | ||
| 341 | doc := ` | 542 | doc := ` | ||
| 342 | name = "demo" | 543 | name = "demo" | ||
| 343 | - | [[service]] | 544 | + | [services.worker] |
| 344 | - | name = "worker" | |||
| 345 | image = "registry.example.com/demo/worker:1" | 545 | image = "registry.example.com/demo/worker:1" | ||
| 346 | devices = ["Bad Name!"] | 546 | devices = ["Bad Name!"] | ||
| 347 | ` | 547 | ` | ||
| @@ -352,8 +552,7 @@ devices = ["Bad Name!"] | |||||
| 352 | func TestParse_DevicesDuplicateRejected(t *testing.T) { | 552 | func TestParse_DevicesDuplicateRejected(t *testing.T) { | ||
| 353 | doc := ` | 553 | doc := ` | ||
| 354 | name = "demo" | 554 | name = "demo" | ||
| 355 | - | [[service]] | 555 | + | [services.worker] |
| 356 | - | name = "worker" | |||
| 357 | image = "registry.example.com/demo/worker:1" | 556 | image = "registry.example.com/demo/worker:1" | ||
| 358 | devices = ["test_gpu", "test_gpu"] | 557 | devices = ["test_gpu", "test_gpu"] | ||
| 359 | ` | 558 | ` | ||
| @@ -365,8 +564,7 @@ devices = ["test_gpu", "test_gpu"] | |||||
| 365 | func TestParse_UnknownGPUFieldRejected(t *testing.T) { | 564 | func TestParse_UnknownGPUFieldRejected(t *testing.T) { | ||
| 366 | doc := ` | 565 | doc := ` | ||
| 367 | name = "demo" | 566 | name = "demo" | ||
| 368 | - | [[service]] | 567 | + | [services.worker] |
| 369 | - | name = "worker" | |||
| 370 | image = "registry.example.com/demo/worker:1" | 568 | image = "registry.example.com/demo/worker:1" | ||
| 371 | gpus = "all" | 569 | gpus = "all" | ||
| 372 | ` | 570 | ` | ||
M internal/adapters/in/appmanifest/parse_visibility_test.go +20 -29
| @@ -17,10 +17,9 @@ func TestParse_VisibilityNormalization(t *testing.T) { | |||
| 17 | 17 | t.Run("absent visibility stays public with auto tls", func(t *testing.T) { | |
| 18 | 18 | doc := ` | |
| 19 | 19 | name = "blog" | |
| 20 | - | [[service]] | |
| 21 | - | name = "web" | |
| 20 | + | [services.web] | |
| 22 | 21 | image = "registry.example.com/blog/web:1.0.0" | |
| 23 | - | [[service.http]] | |
| 22 | + | [[services.web.http]] | |
| 24 | 23 | host = "blog.example.com" | |
| 25 | 24 | port = 8080 | |
| 26 | 25 | ` | |
| @@ -35,13 +34,12 @@ port = 8080 | |||
| 35 | 34 | t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) { | |
| 36 | 35 | doc := ` | |
| 37 | 36 | name = "blog" | |
| 38 | - | [[service]] | |
| 39 | - | name = "api" | |
| 37 | + | [services.api] | |
| 40 | 38 | image = "registry.example.com/blog/api:1.0.0" | |
| 41 | - | [service.readiness] | |
| 39 | + | [services.api.readiness] | |
| 42 | 40 | type = "http" | |
| 43 | 41 | path = "/healthz" | |
| 44 | - | [[service.http]] | |
| 42 | + | [[services.api.http]] | |
| 45 | 43 | visibility = "internal" | |
| 46 | 44 | port = 8080 | |
| 47 | 45 | ` | |
| @@ -58,10 +56,9 @@ port = 8080 | |||
| 58 | 56 | t.Run("explicit public normalizes to auto tls", func(t *testing.T) { | |
| 59 | 57 | doc := ` | |
| 60 | 58 | name = "blog" | |
| 61 | - | [[service]] | |
| 62 | - | name = "web" | |
| 59 | + | [services.web] | |
| 63 | 60 | image = "registry.example.com/blog/web:1.0.0" | |
| 64 | - | [[service.http]] | |
| 61 | + | [[services.web.http]] | |
| 65 | 62 | host = "blog.example.com" | |
| 66 | 63 | port = 8080 | |
| 67 | 64 | visibility = "public" | |
| @@ -79,53 +76,48 @@ func TestParse_VisibilityRejections(t *testing.T) { | |||
| 79 | 76 | tests := map[string]string{ | |
| 80 | 77 | "internal forbids host": ` | |
| 81 | 78 | name = "blog" | |
| 82 | - | [[service]] | |
| 83 | - | name = "api" | |
| 79 | + | [services.api] | |
| 84 | 80 | image = "registry.example.com/blog/api:1.0.0" | |
| 85 | - | [[service.http]] | |
| 81 | + | [[services.api.http]] | |
| 86 | 82 | host = "blog.example.com" | |
| 87 | 83 | port = 8080 | |
| 88 | 84 | visibility = "internal" | |
| 89 | 85 | `, | |
| 90 | 86 | "internal forbids tls": ` | |
| 91 | 87 | name = "blog" | |
| 92 | - | [[service]] | |
| 93 | - | name = "api" | |
| 88 | + | [services.api] | |
| 94 | 89 | image = "registry.example.com/blog/api:1.0.0" | |
| 95 | - | [[service.http]] | |
| 90 | + | [[services.api.http]] | |
| 96 | 91 | port = 8080 | |
| 97 | 92 | visibility = "internal" | |
| 98 | 93 | tls = "auto" | |
| 99 | 94 | `, | |
| 100 | 95 | "internal forbids explicitly empty tls": ` | |
| 101 | 96 | name = "blog" | |
| 102 | - | [[service]] | |
| 103 | - | name = "api" | |
| 97 | + | [services.api] | |
| 104 | 98 | image = "registry.example.com/blog/api:1.0.0" | |
| 105 | - | [[service.http]] | |
| 99 | + | [[services.api.http]] | |
| 106 | 100 | port = 8080 | |
| 107 | 101 | visibility = "internal" | |
| 108 | 102 | tls = "" | |
| 109 | 103 | `, | |
| 110 | 104 | "unknown visibility value": ` | |
| 111 | 105 | name = "blog" | |
| 112 | - | [[service]] | |
| 113 | - | name = "web" | |
| 106 | + | [services.web] | |
| 114 | 107 | image = "registry.example.com/blog/web:1.0.0" | |
| 115 | - | [[service.http]] | |
| 108 | + | [[services.web.http]] | |
| 116 | 109 | host = "blog.example.com" | |
| 117 | 110 | port = 8080 | |
| 118 | 111 | visibility = "vpn" | |
| 119 | 112 | `, | |
| 120 | 113 | "internal port also published as tcp": ` | |
| 121 | 114 | name = "blog" | |
| 122 | - | [[service]] | |
| 123 | - | name = "api" | |
| 115 | + | [services.api] | |
| 124 | 116 | image = "registry.example.com/blog/api:1.0.0" | |
| 125 | - | [[service.http]] | |
| 117 | + | [[services.api.http]] | |
| 126 | 118 | port = 8080 | |
| 127 | 119 | visibility = "internal" | |
| 128 | - | [[service.tcp]] | |
| 120 | + | [[services.api.tcp]] | |
| 129 | 121 | port = 8080 | |
| 130 | 122 | `, | |
| 131 | 123 | } | |
| @@ -143,10 +135,9 @@ port = 8080 | |||
| 143 | 135 | func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) { | |
| 144 | 136 | doc := ` | |
| 145 | 137 | name = "blog" | |
| 146 | - | [[service]] | |
| 147 | - | name = "web" | |
| 138 | + | [services.web] | |
| 148 | 139 | image = "registry.example.com/blog/web:1.0.0" | |
| 149 | - | [[service.http]] | |
| 140 | + | [[services.web.http]] | |
| 150 | 141 | host = "blog.example.com" | |
| 151 | 142 | port = 8080 | |
| 152 | 143 | visibilities = "internal" | |
| @@ -17,10 +17,9 @@ func TestParse_VisibilityNormalization(t *testing.T) { | |||||
| 17 | t.Run("absent visibility stays public with auto tls", func(t *testing.T) { | 17 | t.Run("absent visibility stays public with auto tls", func(t *testing.T) { | ||
| 18 | doc := ` | 18 | doc := ` | ||
| 19 | name = "blog" | 19 | name = "blog" | ||
| 20 | - | [[service]] | 20 | + | [services.web] |
| 21 | - | name = "web" | |||
| 22 | image = "registry.example.com/blog/web:1.0.0" | 21 | image = "registry.example.com/blog/web:1.0.0" | ||
| 23 | - | [[service.http]] | 22 | + | [[services.web.http]] |
| 24 | host = "blog.example.com" | 23 | host = "blog.example.com" | ||
| 25 | port = 8080 | 24 | port = 8080 | ||
| 26 | ` | 25 | ` | ||
| @@ -35,13 +34,12 @@ port = 8080 | |||||
| 35 | t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) { | 34 | t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) { | ||
| 36 | doc := ` | 35 | doc := ` | ||
| 37 | name = "blog" | 36 | name = "blog" | ||
| 38 | - | [[service]] | 37 | + | [services.api] |
| 39 | - | name = "api" | |||
| 40 | image = "registry.example.com/blog/api:1.0.0" | 38 | image = "registry.example.com/blog/api:1.0.0" | ||
| 41 | - | [service.readiness] | 39 | + | [services.api.readiness] |
| 42 | type = "http" | 40 | type = "http" | ||
| 43 | path = "/healthz" | 41 | path = "/healthz" | ||
| 44 | - | [[service.http]] | 42 | + | [[services.api.http]] |
| 45 | visibility = "internal" | 43 | visibility = "internal" | ||
| 46 | port = 8080 | 44 | port = 8080 | ||
| 47 | ` | 45 | ` | ||
| @@ -58,10 +56,9 @@ port = 8080 | |||||
| 58 | t.Run("explicit public normalizes to auto tls", func(t *testing.T) { | 56 | t.Run("explicit public normalizes to auto tls", func(t *testing.T) { | ||
| 59 | doc := ` | 57 | doc := ` | ||
| 60 | name = "blog" | 58 | name = "blog" | ||
| 61 | - | [[service]] | 59 | + | [services.web] |
| 62 | - | name = "web" | |||
| 63 | image = "registry.example.com/blog/web:1.0.0" | 60 | image = "registry.example.com/blog/web:1.0.0" | ||
| 64 | - | [[service.http]] | 61 | + | [[services.web.http]] |
| 65 | host = "blog.example.com" | 62 | host = "blog.example.com" | ||
| 66 | port = 8080 | 63 | port = 8080 | ||
| 67 | visibility = "public" | 64 | visibility = "public" | ||
| @@ -79,53 +76,48 @@ func TestParse_VisibilityRejections(t *testing.T) { | |||||
| 79 | tests := map[string]string{ | 76 | tests := map[string]string{ | ||
| 80 | "internal forbids host": ` | 77 | "internal forbids host": ` | ||
| 81 | name = "blog" | 78 | name = "blog" | ||
| 82 | - | [[service]] | 79 | + | [services.api] |
| 83 | - | name = "api" | |||
| 84 | image = "registry.example.com/blog/api:1.0.0" | 80 | image = "registry.example.com/blog/api:1.0.0" | ||
| 85 | - | [[service.http]] | 81 | + | [[services.api.http]] |
| 86 | host = "blog.example.com" | 82 | host = "blog.example.com" | ||
| 87 | port = 8080 | 83 | port = 8080 | ||
| 88 | visibility = "internal" | 84 | visibility = "internal" | ||
| 89 | `, | 85 | `, | ||
| 90 | "internal forbids tls": ` | 86 | "internal forbids tls": ` | ||
| 91 | name = "blog" | 87 | name = "blog" | ||
| 92 | - | [[service]] | 88 | + | [services.api] |
| 93 | - | name = "api" | |||
| 94 | image = "registry.example.com/blog/api:1.0.0" | 89 | image = "registry.example.com/blog/api:1.0.0" | ||
| 95 | - | [[service.http]] | 90 | + | [[services.api.http]] |
| 96 | port = 8080 | 91 | port = 8080 | ||
| 97 | visibility = "internal" | 92 | visibility = "internal" | ||
| 98 | tls = "auto" | 93 | tls = "auto" | ||
| 99 | `, | 94 | `, | ||
| 100 | "internal forbids explicitly empty tls": ` | 95 | "internal forbids explicitly empty tls": ` | ||
| 101 | name = "blog" | 96 | name = "blog" | ||
| 102 | - | [[service]] | 97 | + | [services.api] |
| 103 | - | name = "api" | |||
| 104 | image = "registry.example.com/blog/api:1.0.0" | 98 | image = "registry.example.com/blog/api:1.0.0" | ||
| 105 | - | [[service.http]] | 99 | + | [[services.api.http]] |
| 106 | port = 8080 | 100 | port = 8080 | ||
| 107 | visibility = "internal" | 101 | visibility = "internal" | ||
| 108 | tls = "" | 102 | tls = "" | ||
| 109 | `, | 103 | `, | ||
| 110 | "unknown visibility value": ` | 104 | "unknown visibility value": ` | ||
| 111 | name = "blog" | 105 | name = "blog" | ||
| 112 | - | [[service]] | 106 | + | [services.web] |
| 113 | - | name = "web" | |||
| 114 | image = "registry.example.com/blog/web:1.0.0" | 107 | image = "registry.example.com/blog/web:1.0.0" | ||
| 115 | - | [[service.http]] | 108 | + | [[services.web.http]] |
| 116 | host = "blog.example.com" | 109 | host = "blog.example.com" | ||
| 117 | port = 8080 | 110 | port = 8080 | ||
| 118 | visibility = "vpn" | 111 | visibility = "vpn" | ||
| 119 | `, | 112 | `, | ||
| 120 | "internal port also published as tcp": ` | 113 | "internal port also published as tcp": ` | ||
| 121 | name = "blog" | 114 | name = "blog" | ||
| 122 | - | [[service]] | 115 | + | [services.api] |
| 123 | - | name = "api" | |||
| 124 | image = "registry.example.com/blog/api:1.0.0" | 116 | image = "registry.example.com/blog/api:1.0.0" | ||
| 125 | - | [[service.http]] | 117 | + | [[services.api.http]] |
| 126 | port = 8080 | 118 | port = 8080 | ||
| 127 | visibility = "internal" | 119 | visibility = "internal" | ||
| 128 | - | [[service.tcp]] | 120 | + | [[services.api.tcp]] |
| 129 | port = 8080 | 121 | port = 8080 | ||
| 130 | `, | 122 | `, | ||
| 131 | } | 123 | } | ||
| @@ -143,10 +135,9 @@ port = 8080 | |||||
| 143 | func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) { | 135 | func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) { | ||
| 144 | doc := ` | 136 | doc := ` | ||
| 145 | name = "blog" | 137 | name = "blog" | ||
| 146 | - | [[service]] | 138 | + | [services.web] |
| 147 | - | name = "web" | |||
| 148 | image = "registry.example.com/blog/web:1.0.0" | 139 | image = "registry.example.com/blog/web:1.0.0" | ||
| 149 | - | [[service.http]] | 140 | + | [[services.web.http]] |
| 150 | host = "blog.example.com" | 141 | host = "blog.example.com" | ||
| 151 | port = 8080 | 142 | port = 8080 | ||
| 152 | visibilities = "internal" | 143 | visibilities = "internal" | ||
M internal/adapters/in/cli/apps_local_integration_test.go +2 -3
| @@ -26,10 +26,9 @@ import ( | |||
| 26 | 26 | // surface (one service, one HTTP host). | |
| 27 | 27 | const validLocalAppManifest = ` | |
| 28 | 28 | name = "blog" | |
| 29 | - | [[service]] | |
| 30 | - | name = "web" | |
| 29 | + | [services.web] | |
| 31 | 30 | image = "img:1" | |
| 32 | - | [[service.http]] | |
| 31 | + | [[services.web.http]] | |
| 33 | 32 | host = "blog.example.com" | |
| 34 | 33 | port = 8080 | |
| 35 | 34 | ` | |
| @@ -26,10 +26,9 @@ import ( | |||||
| 26 | // surface (one service, one HTTP host). | 26 | // surface (one service, one HTTP host). | ||
| 27 | const validLocalAppManifest = ` | 27 | const validLocalAppManifest = ` | ||
| 28 | name = "blog" | 28 | name = "blog" | ||
| 29 | - | [[service]] | 29 | + | [services.web] |
| 30 | - | name = "web" | |||
| 31 | image = "img:1" | 30 | image = "img:1" | ||
| 32 | - | [[service.http]] | 31 | + | [[services.web.http]] |
| 33 | host = "blog.example.com" | 32 | host = "blog.example.com" | ||
| 34 | port = 8080 | 33 | port = 8080 | ||
| 35 | ` | 34 | ` | ||
M internal/adapters/in/http/admin/handler_apps_test.go +2 -3
| @@ -81,10 +81,9 @@ func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, bod | |||
| 81 | 81 | ||
| 82 | 82 | const validAppManifest = ` | |
| 83 | 83 | name = "blog" | |
| 84 | - | [[service]] | |
| 85 | - | name = "web" | |
| 84 | + | [services.web] | |
| 86 | 85 | image = "img:1" | |
| 87 | - | [[service.http]] | |
| 86 | + | [[services.web.http]] | |
| 88 | 87 | host = "blog.example.com" | |
| 89 | 88 | port = 8080 | |
| 90 | 89 | ` | |
| @@ -81,10 +81,9 @@ func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, bod | |||||
| 81 | 81 | ||||
| 82 | const validAppManifest = ` | 82 | const validAppManifest = ` | ||
| 83 | name = "blog" | 83 | name = "blog" | ||
| 84 | - | [[service]] | 84 | + | [services.web] |
| 85 | - | name = "web" | |||
| 86 | image = "img:1" | 85 | image = "img:1" | ||
| 87 | - | [[service.http]] | 86 | + | [[services.web.http]] |
| 88 | host = "blog.example.com" | 87 | host = "blog.example.com" | ||
| 89 | port = 8080 | 88 | port = 8080 | ||
| 90 | ` | 89 | ` | ||
M internal/app/run.go +7 -5
| @@ -129,7 +129,7 @@ type Config struct { | |||
| 129 | 129 | } `mapstructure:"apps"` | |
| 130 | 130 | ||
| 131 | 131 | // AppMounts declares administrative bind policies keyed by stable mount | |
| 132 | - | // name, referenced by name from an app manifest's [[service.bind]]. | |
| 132 | + | // name, referenced by name from an app manifest's [[services.<name>.bind]]. | |
| 133 | 133 | AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"` | |
| 134 | 134 | ||
| 135 | 135 | // AppDevices declares administrative device grants keyed by stable | |
| @@ -463,15 +463,17 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 463 | 463 | // retiredAppConfigKeys are pre-v3 application keys removed with the | |
| 464 | 464 | // declarative-apps cutover. Presence fails boot/reload closed with a | |
| 465 | 465 | // config-retired diagnostic naming the fix — never a silent migration. | |
| 466 | + | // Keys that remain live installation-level settings are deliberately | |
| 467 | + | // absent: top-level "services" still declares standalone L4 workloads | |
| 468 | + | // (see config.services), so it must never be rejected here. | |
| 466 | 469 | var retiredAppConfigKeys = []struct { | |
| 467 | 470 | key string | |
| 468 | 471 | hint string | |
| 469 | 472 | }{ | |
| 470 | - | {"routes", "declare [[service.http]] in an app file, then 'gordon apps apply'"}, | |
| 471 | - | {"attachments", "declare [[service]] + volumes; attachments are removed"}, | |
| 473 | + | {"routes", "declare [[services.<name>.http]] in an app file, then 'gordon apps apply'"}, | |
| 474 | + | {"attachments", "declare [services.<name>] + volumes; attachments are removed"}, | |
| 472 | 475 | {"network_groups", "declare [[network.shared]] with ownership verification"}, | |
| 473 | - | {"services", "one [[service]] per app file"}, | |
| 474 | - | {"service_routes", "declare [[service.http]] instead"}, | |
| 476 | + | {"service_routes", "declare [[services.<name>.http]] instead"}, | |
| 475 | 477 | {"auto", "feature removed; declare explicit interfaces"}, | |
| 476 | 478 | {"auto_route", "feature removed; declare explicit interfaces"}, | |
| 477 | 479 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | |
| @@ -129,7 +129,7 @@ type Config struct { | |||||
| 129 | } `mapstructure:"apps"` | 129 | } `mapstructure:"apps"` | ||
| 130 | 130 | ||||
| 131 | // AppMounts declares administrative bind policies keyed by stable mount | 131 | // AppMounts declares administrative bind policies keyed by stable mount | ||
| 132 | - | // name, referenced by name from an app manifest's [[service.bind]]. | 132 | + | // name, referenced by name from an app manifest's [[services.<name>.bind]]. |
| 133 | AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"` | 133 | AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"` | ||
| 134 | 134 | ||||
| 135 | // AppDevices declares administrative device grants keyed by stable | 135 | // AppDevices declares administrative device grants keyed by stable | ||
| @@ -463,15 +463,17 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||||
| 463 | // retiredAppConfigKeys are pre-v3 application keys removed with the | 463 | // retiredAppConfigKeys are pre-v3 application keys removed with the | ||
| 464 | // declarative-apps cutover. Presence fails boot/reload closed with a | 464 | // declarative-apps cutover. Presence fails boot/reload closed with a | ||
| 465 | // config-retired diagnostic naming the fix — never a silent migration. | 465 | // config-retired diagnostic naming the fix — never a silent migration. | ||
| 466 | + | // Keys that remain live installation-level settings are deliberately | |||
| 467 | + | // absent: top-level "services" still declares standalone L4 workloads | |||
| 468 | + | // (see config.services), so it must never be rejected here. | |||
| 466 | var retiredAppConfigKeys = []struct { | 469 | var retiredAppConfigKeys = []struct { | ||
| 467 | key string | 470 | key string | ||
| 468 | hint string | 471 | hint string | ||
| 469 | }{ | 472 | }{ | ||
| 470 | - | {"routes", "declare [[service.http]] in an app file, then 'gordon apps apply'"}, | 473 | + | {"routes", "declare [[services.<name>.http]] in an app file, then 'gordon apps apply'"}, |
| 471 | - | {"attachments", "declare [[service]] + volumes; attachments are removed"}, | 474 | + | {"attachments", "declare [services.<name>] + volumes; attachments are removed"}, |
| 472 | {"network_groups", "declare [[network.shared]] with ownership verification"}, | 475 | {"network_groups", "declare [[network.shared]] with ownership verification"}, | ||
| 473 | - | {"services", "one [[service]] per app file"}, | 476 | + | {"service_routes", "declare [[services.<name>.http]] instead"}, |
| 474 | - | {"service_routes", "declare [[service.http]] instead"}, | |||
| 475 | {"auto", "feature removed; declare explicit interfaces"}, | 477 | {"auto", "feature removed; declare explicit interfaces"}, | ||
| 476 | {"auto_route", "feature removed; declare explicit interfaces"}, | 478 | {"auto_route", "feature removed; declare explicit interfaces"}, | ||
| 477 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | 479 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | ||
A internal/app/run_example_config_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestInitConfigAcceptsShippedExampleConfig guards the example shipped in | |
| 11 | + | // the image: it must load cleanly, so no retired key may reappear as an | |
| 12 | + | // active table. Viper reports a table that only holds comments as present | |
| 13 | + | // in the config, so an empty [routes]-style header fails startup. | |
| 14 | + | func TestInitConfigAcceptsShippedExampleConfig(t *testing.T) { | |
| 15 | + | path, err := filepath.Abs(filepath.Join("..", "..", "gordon.toml.example")) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | ||
| 18 | + | _, _, err = initConfig(path) | |
| 19 | + | ||
| 20 | + | require.NoError(t, err) | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TestInitConfigAcceptsShippedExampleConfig guards the example shipped in | |||
| 11 | + | // the image: it must load cleanly, so no retired key may reappear as an | |||
| 12 | + | // active table. Viper reports a table that only holds comments as present | |||
| 13 | + | // in the config, so an empty [routes]-style header fails startup. | |||
| 14 | + | func TestInitConfigAcceptsShippedExampleConfig(t *testing.T) { | |||
| 15 | + | path, err := filepath.Abs(filepath.Join("..", "..", "gordon.toml.example")) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | ||||
| 18 | + | _, _, err = initConfig(path) | |||
| 19 | + | ||||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | } | |||
A internal/app/run_standalone_services_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // TestInitConfigAcceptsStandaloneServices proves the installation-level | |
| 12 | + | // [[services]] L4 standalone workload key is still live config and must | |
| 13 | + | // not be rejected as a retired app key. App workloads now live in | |
| 14 | + | // standalone app files, but [[services]] in gordon.toml keeps managing | |
| 15 | + | // Gordon-owned L4 containers. | |
| 16 | + | func TestInitConfigAcceptsStandaloneServices(t *testing.T) { | |
| 17 | + | configPath := filepath.Join(t.TempDir(), "gordon.toml") | |
| 18 | + | doc := ` | |
| 19 | + | [[services]] | |
| 20 | + | name = "rust" | |
| 21 | + | image = "registry.example.com:5000/rust:latest" | |
| 22 | + | enabled = true | |
| 23 | + | ||
| 24 | + | [[services.ports]] | |
| 25 | + | name = "game" | |
| 26 | + | container = 28015 | |
| 27 | + | protocol = "udp" | |
| 28 | + | publish = "127.0.0.1:38015" | |
| 29 | + | ` | |
| 30 | + | require.NoError(t, os.WriteFile(configPath, []byte(doc), 0o600)) | |
| 31 | + | ||
| 32 | + | _, cfg, err := initConfig(configPath) | |
| 33 | + | ||
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Len(t, cfg.Services, 1) | |
| 36 | + | require.Equal(t, "rust", cfg.Services[0].Name) | |
| 37 | + | require.True(t, cfg.Services[0].Enabled) | |
| 38 | + | require.Len(t, cfg.Services[0].Ports, 1) | |
| 39 | + | require.Equal(t, 28015, cfg.Services[0].Ports[0].Container) | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // TestInitConfigAcceptsStandaloneServices proves the installation-level | |||
| 12 | + | // [[services]] L4 standalone workload key is still live config and must | |||
| 13 | + | // not be rejected as a retired app key. App workloads now live in | |||
| 14 | + | // standalone app files, but [[services]] in gordon.toml keeps managing | |||
| 15 | + | // Gordon-owned L4 containers. | |||
| 16 | + | func TestInitConfigAcceptsStandaloneServices(t *testing.T) { | |||
| 17 | + | configPath := filepath.Join(t.TempDir(), "gordon.toml") | |||
| 18 | + | doc := ` | |||
| 19 | + | [[services]] | |||
| 20 | + | name = "rust" | |||
| 21 | + | image = "registry.example.com:5000/rust:latest" | |||
| 22 | + | enabled = true | |||
| 23 | + | ||||
| 24 | + | [[services.ports]] | |||
| 25 | + | name = "game" | |||
| 26 | + | container = 28015 | |||
| 27 | + | protocol = "udp" | |||
| 28 | + | publish = "127.0.0.1:38015" | |||
| 29 | + | ` | |||
| 30 | + | require.NoError(t, os.WriteFile(configPath, []byte(doc), 0o600)) | |||
| 31 | + | ||||
| 32 | + | _, cfg, err := initConfig(configPath) | |||
| 33 | + | ||||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.Len(t, cfg.Services, 1) | |||
| 36 | + | require.Equal(t, "rust", cfg.Services[0].Name) | |||
| 37 | + | require.True(t, cfg.Services[0].Enabled) | |||
| 38 | + | require.Len(t, cfg.Services[0].Ports, 1) | |||
| 39 | + | require.Equal(t, 28015, cfg.Services[0].Ports[0].Container) | |||
| 40 | + | } | |||