detached 7e66aa6

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 UnverifiedB5690EEEBB952194 The key is unknown
24 files · +581 -226
M README.md +2 -3
@@ -56,11 +56,10 @@ Minimal app file (`blog.toml`):
5656 ```toml
5757 name = "blog"
5858
59-[[service]]
60-name = "web"
59+[services.web]
6160 image = "gordon.mydomain.com/myapp:v1.2.0"
6261
63-[[service.http]]
62+[[services.web.http]]
6463 host = "blog.mydomain.com"
6564 port = 3000
6665 ```
@@ -56,11 +56,10 @@ Minimal app file (`blog.toml`):
56 ```toml56 ```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 = 300064 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
1111 which of them are backed up:
1212
1313 ```toml
14-[[service]]
15-name = "api"
14+[services.api]
1615 image = "registry.example.com/shop/api:1.4.2"
1716
18-[[service.database]]
17+[[services.api.database]]
1918 name = "orders"
2019 type = "postgres"
2120 schedule = "daily"
2221
23-[[service.volume]]
22+[[services.api.volume]]
2423 name = "data"
2524 path = "/var/lib/data"
2625
27-[service.backup]
26+[services.api.backup]
2827 postgres = ["orders"]
2928 volume = ["data"]
3029 ```
@@ -32,7 +31,7 @@ volume = ["data"]
3231 A declared database or volume that the service's backup declaration does not
3332 reference is not a backup target.
3433
35-Administrative bind mounts are never backup targets. `[service.backup]` accepts
34+Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts
3635 only declared databases and volumes, and Gordon never archives an
3736 operator-owned host path exposed through a bind.
3837
@@ -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 ```toml13 ```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 not31 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]` accepts34+Administrative bind mounts are never backup targets. `[services.<name>.backup]` accepts
36 only declared databases and volumes, and Gordon never archives an35 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"
4848 [env]
4949 APP_ENV = "production" # app-wide public env, injected into all services
5050
51-[[service]]
52-name = "web"
51+[services.web]
5352 image = "gordon.mydomain.com/blog:1.4.2"
5453
55-[[service.http]]
54+[[services.web.http]]
5655 host = "blog.mydomain.com"
5756 port = 3000
5857
59-[service.secrets] # ENV name -> secret name (values stay in pass)
58+[services.web.secrets] # ENV name -> secret name (values stay in pass)
6059 DATABASE_URL = "database-url"
6160 ```
6261
@@ -101,7 +100,7 @@ Safe removal is the default. `gordon apps remove` withdraws workloads and frees
101100 An app's HTTP interfaces declare the hosts Gordon serves:
102101
103102 ```toml
104-[[service.http]]
103+[[services.web.http]]
105104 host = "app.example.com"
106105 port = 3000
107106 ```
@@ -131,7 +130,7 @@ Deploy adds AND removes memberships without disconnecting unrelated services. Sh
131130 Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes:
132131
133132 ```toml
134-[[service.volume]]
133+[[services.web.volume]]
135134 name = "web-data"
136135 path = "/data"
137136 ```
@@ -152,7 +151,7 @@ APP_ENV = "production"
152151 Service-specific values use `secrets` even when non-confidential:
153152
154153 ```toml
155-[service.secrets]
154+[services.web.secrets]
156155 DATABASE_URL = "database-url"
157156 ```
158157
@@ -174,7 +173,7 @@ Gordon uses an internal event system for coordination. Registry storage events n
174173
175174 ## Backups and Recovery
176175
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.
178177
179178 Stored backups are never deleted when declarations change — only schedules update on deploy.
180179
@@ -48,15 +48,14 @@ name = "blog"
48 [env]48 [env]
49 APP_ENV = "production" # app-wide public env, injected into all services49 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 = 300056 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 ```toml102 ```toml
104-[[service.http]]103+[[services.web.http]]
105 host = "app.example.com"104 host = "app.example.com"
106 port = 3000105 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 ```toml132 ```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 ```toml153 ```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 Recovery174 ## 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
99 ```toml
1010 name = "blog"
1111
12-[[service]]
13-name = "web"
12+[services.web]
1413 image = "gordon.mydomain.com/blog:1.4.2"
1514
16-[[service.http]]
15+[[services.web.http]]
1716 host = "blog.mydomain.com"
1817 port = 3000
1918 ```
2019
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+
2122 ## Full Example
2223
2324 ```toml
@@ -26,47 +27,46 @@ name = "blog"
2627 [env] # optional, app-wide public env for all services
2728 APP_ENV = "production"
2829
29-[[service]]
30-name = "web"
30+[services.web] # one table per service; the key is the name
3131 image = "gordon.mydomain.com/blog:1.4.2"
3232 command = ["node", "server.js"] # optional override
3333 stop_grace = "30s" # optional, default 30s
3434 devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>]
3535
36-[service.readiness] # optional explicit readiness
36+[services.web.readiness] # optional explicit readiness
3737 type = "http"
3838 path = "/healthz"
3939 port = 3000
4040 timeout = "30s"
4141
42-[[service.http]] # 0..n HTTP interfaces
42+[[services.web.http]] # 0..n HTTP interfaces
4343 host = "blog.mydomain.com"
4444 port = 3000
4545 tls = "auto" # auto | always | never
4646
47-[[service.http]] # optional private interface
47+[[services.web.http]] # optional private interface
4848 visibility = "internal" # public (default) | internal
4949 port = 8080 # required; no host or tls
5050
51-[service.secrets] # ENV name -> secret name (values in pass)
51+[services.web.secrets] # ENV name -> secret name (values in pass)
5252 DATABASE_URL = "database-url"
5353
54-[[service.volume]] # 0..n named volumes only
54+[[services.web.volume]] # 0..n named volumes only
5555 name = "web-data"
5656 path = "/data"
5757 readonly = false
5858
59-[[service.bind]] # 0..n; name references [app_mounts.<name>]
59+[[services.web.bind]] # 0..n; name references [app_mounts.<name>]
6060 name = "app-logs" # policy name, never a host path
6161 path = "/var/log/app" # absolute container destination
6262 readonly = true
6363
64-[[service.database]] # explicit database declarations
64+[[services.web.database]] # explicit database declarations
6565 name = "main"
6666 type = "postgres"
6767 schedule = "daily"
6868
69-[service.backup] # backup targets by reference
69+[services.web.backup] # backup targets by reference
7070 postgres = ["main"]
7171 volume = ["web-data"]
7272
@@ -78,22 +78,22 @@ services = ["web"]
7878 ## Identity Rules
7979
8080 - 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.
8282 - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
8383 - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
8484 - 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.
8585
8686 ## Services
8787
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.
8989
9090 ```toml
91-[[service.tcp]]
91+[[services.web.tcp]]
9292 entrypoint = "game" # names installation entrypoints.<name>
9393 port = 25565
9494 publish = "0.0.0.0:25565"
9595
96-[[service.udp]]
96+[[services.web.udp]]
9797 entrypoint = "game"
9898 port = 28015
9999 publish = "0.0.0.0:28015"
@@ -107,33 +107,33 @@ Image registry names and digest syntax are validated during manifest apply, reso
107107
108108 ## Environment and Secrets
109109
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.
113113
114114 ## Volumes and Databases
115115
116116 - 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).
118118 - 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.
120120 - `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.
121121 - A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once.
122122 - 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.
125125
126126 ## HTTP Interfaces
127127
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.
129129
130130 ```toml
131-[[service.http]] # public: proxied by host
131+[[services.web.http]] # public: proxied by host
132132 host = "blog.mydomain.com"
133133 port = 3000
134134 tls = "auto" # auto | always | never
135135
136-[[service.http]] # internal: not published (app private network only)
136+[[services.web.http]] # internal: not published (app private network only)
137137 visibility = "internal"
138138 port = 8080
139139 ```
@@ -147,7 +147,7 @@ A container port declared by both an internal HTTP interface and an externally b
147147
148148 ## Readiness
149149
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.
151151
152152 ## TLS
153153
@@ -167,6 +167,8 @@ Services of the same app communicate over that private network and resolve each
167167
168168 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.
169169
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+
170172 ## Related
171173
172174 - [Apps CLI](../cli/apps.md)
@@ -9,15 +9,16 @@ The file is intended for Git: it must never contain secret values. Service-speci
9 ```toml9 ```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 = 300017 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 Example22 ## Full Example
22 23
23 ```toml24 ```toml
@@ -26,47 +27,46 @@ name = "blog"
26 [env] # optional, app-wide public env for all services27 [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 override32 command = ["node", "server.js"] # optional override
33 stop_grace = "30s" # optional, default 30s33 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 readiness36+[services.web.readiness] # optional explicit readiness
37 type = "http"37 type = "http"
38 path = "/healthz"38 path = "/healthz"
39 port = 300039 port = 3000
40 timeout = "30s"40 timeout = "30s"
41 41
42-[[service.http]] # 0..n HTTP interfaces42+[[services.web.http]] # 0..n HTTP interfaces
43 host = "blog.mydomain.com"43 host = "blog.mydomain.com"
44 port = 300044 port = 3000
45 tls = "auto" # auto | always | never45 tls = "auto" # auto | always | never
46 46
47-[[service.http]] # optional private interface47+[[services.web.http]] # optional private interface
48 visibility = "internal" # public (default) | internal48 visibility = "internal" # public (default) | internal
49 port = 8080 # required; no host or tls49 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 only54+[[services.web.volume]] # 0..n named volumes only
55 name = "web-data"55 name = "web-data"
56 path = "/data"56 path = "/data"
57 readonly = false57 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 path60 name = "app-logs" # policy name, never a host path
61 path = "/var/log/app" # absolute container destination61 path = "/var/log/app" # absolute container destination
62 readonly = true62 readonly = true
63 63
64-[[service.database]] # explicit database declarations64+[[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 reference69+[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 Rules78 ## 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 ## Services86 ## 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 ```toml90 ```toml
91-[[service.tcp]]91+[[services.web.tcp]]
92 entrypoint = "game" # names installation entrypoints.<name>92 entrypoint = "game" # names installation entrypoints.<name>
93 port = 2556593 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 = 2801598 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 Secrets108 ## 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 Databases114 ## 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 Interfaces126 ## 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 ```toml130 ```toml
131-[[service.http]] # public: proxied by host131+[[services.web.http]] # public: proxied by host
132 host = "blog.mydomain.com"132 host = "blog.mydomain.com"
133 port = 3000133 port = 3000
134 tls = "auto" # auto | always | never134 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 = 8080138 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 ## Readiness148 ## 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 ## TLS152 ## 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 ## Related172 ## 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 @@
11 # Environment Variables
22
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.
44
55 ## Configuration
66
@@ -23,7 +23,7 @@ The `[env]` directory backs the installation secret store:
2323 - 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.
2424 - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
2525
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)).
2727
2828 ## Secret Provider Syntax
2929
@@ -1,6 +1,6 @@
1 # Environment Variables1 # 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 ## Configuration5 ## 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 Syntax28 ## Secret Provider Syntax
29 29
M docs/config/index.md +2 -2
@@ -21,7 +21,7 @@ address = ":443"
2121 protocol = "smart_tcp"
2222 ```
2323
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.
2525
2626 > **Note:** `gordon_domain` is the canonical registry and Admin API host.
2727 >
@@ -106,7 +106,7 @@ network_prefix = "gordon" # Network name prefix
106106 internal = false # Set true to block direct egress from isolated networks
107107
108108 # REMOVED in v3 (declare apps in standalone files, see ./apps.md):
109-# [routes], [attachments], [network_groups], [[services]]-as-apps,
109+# [routes], [attachments], [network_groups],
110110 # [service_routes], [auto_route], [previews]
111111
112112 # 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 networks106 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 # Backups112 # Backups
M docs/config/reference.md +9 -8
@@ -142,7 +142,7 @@ preserve = true # Keep volumes when containers are
142142 # ADMINISTRATIVE APP BIND MOUNTS
143143 # =============================================================================
144144 # 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
146146 # paths are always rejected, and the policy's allowlists are exact and
147147 # non-empty. read_only on the policy and readonly on the bind both force
148148 # read-only: either side wins and a bind never weakens its policy.
@@ -168,8 +168,9 @@ preserve = true # Keep volumes when containers are
168168 # allowed_services = ["transcoder"] # Required; exact, non-empty
169169
170170 # 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.
173174
174175 # =============================================================================
175176 # EXTERNAL ROUTES
@@ -342,14 +343,14 @@ Note: for all `backups.retention.*` keys, `0` means keep no backups for that ret
342343
343344 ## App Manifest HTTP Interfaces
344345
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)).
346347
347348 | Key | Values | Default | Description |
348349 |-----|--------|---------|-------------|
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 |
353354
354355 `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.
355356
@@ -142,7 +142,7 @@ preserve = true # Keep volumes when containers are
142 # ADMINISTRATIVE APP BIND MOUNTS142 # 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 host145+# App manifests declare [[services.<name>.bind]] with name = "<mount>"; direct host
146 # paths are always rejected, and the policy's allowlists are exact and146 # 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 force147 # 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-empty168 # 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 ROUTES176 # 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 Interfaces344 ## 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}
139139
140140 ## App Secret Values
141141
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:
143143
144144 ```bash
145145 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 Values140 ## 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 ```bash144 ```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
5353 root = "/srv/gordon" # optional boundary
5454 ```
5555
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`.
5757
5858 - `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.
5959 - `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 boundary53 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
77 Services declare persistent mounts in the app manifest:
88
99 ```toml
10-[[service.volume]]
10+[[services.web.volume]]
1111 name = "database-data"
1212 path = "/var/lib/postgresql/data"
1313 ```
@@ -30,7 +30,7 @@ allowed_services = ["web"] # required; exact, non-empty
3030
3131 ```toml
3232 # app manifest
33-[[service.bind]]
33+[[services.web.bind]]
3434 name = "app-logs" # must match [app_mounts.<name>]
3535 path = "/var/lib/collector/host-logs" # container destination
3636 readonly = true
@@ -47,15 +47,14 @@ A read-only log collector is the typical use. Its own state is a named volume; t
4747 ```toml
4848 name = "metrics-agent"
4949
50-[[service]]
51-name = "collector"
50+[services.collector]
5251 image = "registry.example.com/metrics/collector:2.1.0"
5352
54-[[service.volume]]
53+[[services.collector.volume]]
5554 name = "collector-state"
5655 path = "/var/lib/collector"
5756
58-[[service.bind]]
57+[[services.collector.bind]]
5958 name = "app-logs"
6059 path = "/var/lib/collector/host-logs"
6160 readonly = true
@@ -63,7 +62,7 @@ readonly = true
6362
6463 Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind.
6564
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.
6766
6867 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.
6968
@@ -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 ```toml9 ```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 ```toml31 ```toml
32 # app manifest32 # 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 destination35 path = "/var/lib/collector/host-logs" # container destination
36 readonly = true36 readonly = true
@@ -47,15 +47,14 @@ A read-only log collector is the typical use. Its own state is a named volume; t
47 ```toml47 ```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 = true60 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
113113 Reference it from the app manifest:
114114
115115 ```toml
116-[[service]]
117-name = "web"
116+[services.web]
118117 image = "gordon.example.com/myapp:latest"
119118 ```
120119
@@ -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 ```toml115 ```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
8181 protocol = "smart_tcp"
8282 ```
8383
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.
8585
8686 ## 5. Set Up DNS (Including Wildcard)
8787
@@ -156,11 +156,10 @@ Write the app file (`blog.toml`). The file is intended for Git: it must never co
156156 ```toml
157157 name = "blog"
158158
159-[[service]]
160-name = "web"
159+[services.web]
161160 image = "gordon.mydomain.com/myapp:latest"
162161
163-[[service.http]]
162+[[services.web.http]]
164163 host = "app.mydomain.com"
165164 port = 3000
166165 ```
@@ -178,7 +177,7 @@ What these commands do:
178177 - `gordon apps apply` validates the manifest and persists it as desired state.
179178 - `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.
180179
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:
182181
183182 ```bash
184183 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 ```toml156 ```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 = 3000164 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 ```bash182 ```bash
184 gordon apps secrets set blog --service web APP_ENV=production --remote prod183 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`:
2020 - `[routes]`;
2121 - `[attachments]`;
2222 - `[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);
2424 - `[auto_route]` and `[auto_route_allowed_domains]`;
2525 - `[previews]`.
2626
@@ -40,23 +40,24 @@ Create one app manifest per workload. See [App Manifest](./config/apps.md).
4040 ```toml
4141 name = "example-app"
4242
43-[[service]]
44-name = "web"
43+[services.web]
4544 image = "registry.example.com/example-app:1.2.3"
4645
47-[service.secrets]
46+[services.web.secrets]
4847 DATABASE_URL = "database-url"
4948
50-[[service.http]]
49+[[services.web.http]]
5150 host = "app.example.com"
5251 port = 8080
5352 ```
5453
5554 The manifest contains secret **names**, never secret values.
5655
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+
5758 ## 2. Migrate domain secrets
5859
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:
6061
6162 ```bash
6263 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 ```toml40 ```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 = 808051 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 secrets58 ## 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 ```bash62 ```bash
62 gordon apps apply --file ./example-app.toml --remote production63 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
2828
2929 ### Backup Metadata
3030
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.
3232
3333 ## Image Labels
3434
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.
3636
3737 ## Container Naming
3838
@@ -28,11 +28,11 @@ Containers created before v3 may carry these labels. They are read-only provenan
28 28
29 ### Backup Metadata29 ### 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 Labels33 ## 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 Naming37 ## 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
1010
1111 ### Removed
1212
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).
1415 - 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`.
1516 - 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"`.
1617 - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.
1718
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+
1852 ### Manual migration
1953
2054 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.
2155 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.
2357 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).
2458 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.
2559 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 ### Removed11 ### 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 migration52 ### 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"
112112 # ADMINISTRATIVE APP BIND MOUNTS
113113 # =============================================================================
114114 # 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
116116 # are always rejected. allowed_apps/allowed_services are exact and non-empty,
117117 # and read_only/readonly force read-only: either side wins.
118118 # [app_mounts.app-logs]
@@ -186,7 +186,7 @@ prefix = ""
186186 [backups.volumes.retention]
187187 keep = 14
188188
189-[routes]
189+# [routes] is retired (see REMOVED list below): declaring it fails boot/reload closed.
190190 # "app.example.com" = { image = "myapp:latest" }
191191
192192 [external_routes]
@@ -199,18 +199,19 @@ keep = 14
199199 # one file per app, applied with `gordon apps apply --file <app>.toml`
200200 # and activated with `gordon apps deploy <app>`.
201201 #
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
204204 # 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
207208 # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file
208209 # REMOVED: [previews] -> staging is an ordinary app file
209210
210211 # Gordon-managed standalone L4 services. Use service:<name>:<port> from
211212 # traffic routers to expose published loopback ports. Standalone services
212213 # 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
214215 # managed with `gordon apps`; see docs/cli/apps.md.)
215216 # [[services]]
216217 # name = "rust"
@@ -112,7 +112,7 @@ syslog_identifier = "gordon-access"
112 # ADMINISTRATIVE APP BIND MOUNTS112 # 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 paths115+# 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 = 14187 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 file202+# REMOVED: [routes] -> [[services.<name>.http]] in an app file
203-# REMOVED: [attachments] -> [[service]] + volumes in an app file203+# REMOVED: [attachments] -> [services.<name>] + volumes in an app file
204 # REMOVED: [network_groups] -> [[network.shared]] in an app file204 # REMOVED: [network_groups] -> [[network.shared]] in an app file
205-# REMOVED: [[services]]-as-app -> one [[service]] per app file205+# REMOVED: app-like [[services]] -> one [services.<name>] table per app file
206-# REMOVED: [service_routes] -> [[service.http]] in an app file206+# (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 file208 # REMOVED: [auto]/[auto_route] -> explicit interfaces in an app file
208 # REMOVED: [previews] -> staging is an ordinary app file209 # REMOVED: [previews] -> staging is an ordinary app file
209 210
210 # Gordon-managed standalone L4 services. Use service:<name>:<port> from211 # Gordon-managed standalone L4 services. Use service:<name>:<port> from
211 # traffic routers to expose published loopback ports. Standalone services212 # 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 and214+# (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 (
99 "bytes"
1010 "errors"
1111 "fmt"
12+ "maps"
13+ "slices"
14+ "strconv"
1215 "strings"
1316 "time"
1417
@@ -18,11 +21,12 @@ import (
1821 )
1922
2023 // rawManifest mirrors the frozen TOML schema for strict decoding.
24+// Services is keyed by service name: [services.<name>].
2125 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"`
2630 }
2731
2832 // rawNetwork mirrors [network] and its [[network.shared]] children.
@@ -30,9 +34,9 @@ type rawNetwork struct {
3034 Shared []rawSharedNetwork `toml:"shared"`
3135 }
3236
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.
3439 type rawService struct {
35- Name string `toml:"name"`
3640 Image string `toml:"image"`
3741 Command []string `toml:"command"`
3842 StopGrace string `toml:"stop_grace"`
@@ -50,7 +54,7 @@ type rawService struct {
5054 Backup rawBackup `toml:"backup"`
5155 }
5256
53-// rawReadiness mirrors [service.readiness].
57+// rawReadiness mirrors [services.<name>.readiness].
5458 type rawReadiness struct {
5559 Type string `toml:"type"`
5660 Path string `toml:"path"`
@@ -59,7 +63,7 @@ type rawReadiness struct {
5963 Timeout string `toml:"timeout"`
6064 }
6165
62-// rawHTTP mirrors [[service.http]].
66+// rawHTTP mirrors [[services.<name>.http]].
6367 type rawHTTP struct {
6468 Host string `toml:"host"`
6569 Port int `toml:"port"`
@@ -70,42 +74,42 @@ type rawHTTP struct {
7074 Visibility string `toml:"visibility"`
7175 }
7276
73-// rawTCP mirrors [[service.tcp]].
77+// rawTCP mirrors [[services.<name>.tcp]].
7478 type rawTCP struct {
7579 Entrypoint string `toml:"entrypoint"`
7680 Port int `toml:"port"`
7781 Publish string `toml:"publish"`
7882 }
7983
80-// rawUDP mirrors [[service.udp]].
84+// rawUDP mirrors [[services.<name>.udp]].
8185 type rawUDP struct {
8286 Entrypoint string `toml:"entrypoint"`
8387 Port int `toml:"port"`
8488 Publish string `toml:"publish"`
8589 }
8690
87-// rawVolume mirrors [[service.volume]].
91+// rawVolume mirrors [[services.<name>.volume]].
8892 type rawVolume struct {
8993 Name string `toml:"name"`
9094 Path string `toml:"path"`
9195 ReadOnly bool `toml:"readonly"`
9296 }
9397
94-// rawBind mirrors [[service.bind]].
98+// rawBind mirrors [[services.<name>.bind]].
9599 type rawBind struct {
96100 Name string `toml:"name"`
97101 Path string `toml:"path"`
98102 ReadOnly bool `toml:"readonly"`
99103 }
100104
101-// rawDatabase mirrors [[service.database]].
105+// rawDatabase mirrors [[services.<name>.database]].
102106 type rawDatabase struct {
103107 Name string `toml:"name"`
104108 Type string `toml:"type"`
105109 Schedule string `toml:"schedule"`
106110 }
107111
108-// rawBackup mirrors [service.backup].
112+// rawBackup mirrors [services.<name>.backup].
109113 type rawBackup struct {
110114 Postgres []string `toml:"postgres"`
111115 Volume []string `toml:"volume"`
@@ -124,6 +128,9 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
124128 var raw rawManifest
125129 decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
126130 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+ }
127134 return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err))
128135 }
129136 spec, err := toDomain(raw)
@@ -158,6 +165,43 @@ func formatDecodeError(err error) string {
158165 return "unknown TOML fields or tables: " + strings.Join(unknown, ", ")
159166 }
160167
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+
161205 // toDomain maps raw TOML onto domain types with normalization and defaults.
162206 func toDomain(raw rawManifest) (domain.AppSpec, error) {
163207 spec := domain.AppSpec{
@@ -169,8 +213,11 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
169213 for key, value := range raw.Env {
170214 spec.Env[key] = value
171215 }
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])
174221 if err != nil {
175222 return domain.AppSpec{}, err
176223 }
@@ -205,10 +252,11 @@ func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
205252 }, nil
206253 }
207254
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) {
210258 svc := domain.AppService{
211- Name: raw.Name,
259+ Name: serviceName,
212260 Image: normalizeImage(raw.Image),
213261 Command: append([]string(nil), raw.Command...),
214262 Secrets: map[string]string{},
@@ -221,18 +269,18 @@ func toDomainService(raw rawService) (domain.AppService, error) {
221269 if raw.StopGrace != "" {
222270 parsed, err := time.ParseDuration(raw.StopGrace)
223271 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)
225273 }
226274 stopGrace = parsed
227275 }
228276 svc.StopGrace = stopGrace
229- readiness, err := toDomainReadiness(raw.Name, raw.Readiness)
277+ readiness, err := toDomainReadiness(serviceName, raw.Readiness)
230278 if err != nil {
231279 return domain.AppService{}, err
232280 }
233281 svc.Readiness = readiness
234282 for _, h := range raw.HTTP {
235- iface, err := toDomainHTTP(raw.Name, h)
283+ iface, err := toDomainHTTP(serviceName, h)
236284 if err != nil {
237285 return domain.AppService{}, err
238286 }
@@ -280,7 +328,7 @@ func toDomainService(raw rawService) (domain.AppService, error) {
280328 })
281329 }
282330 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))
284332 }
285333 return svc, nil
286334 }
@@ -307,6 +355,30 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e
307355 return readiness, nil
308356 }
309357
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+
310382 // normalizeImage records a missing tag as explicit :latest.
311383 func normalizeImage(image string) string {
312384 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 rawManifest128 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] = value214 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{}, err222 return domain.AppSpec{}, err
176 }223 }
@@ -205,10 +252,11 @@ func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
205 }, nil252 }, 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 = parsed274 stopGrace = parsed
227 }275 }
228 svc.StopGrace = stopGrace276 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{}, err279 return domain.AppService{}, err
232 }280 }
233 svc.Readiness = readiness281 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{}, err285 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, nil333 return svc, nil
286 }334 }
@@ -307,6 +355,30 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e
307 return readiness, nil355 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"
1717 [env]
1818 APP_ENV = "production"
1919
20-[[service]]
21-name = "web"
20+[services.web]
2221 image = "registry.example.com/blog/web:1.4.2"
2322
24-[service.readiness]
23+[services.web.readiness]
2524 type = "http"
2625 path = "/healthz"
2726
28-[[service.http]]
27+[[services.web.http]]
2928 host = "Blog.Example.COM."
3029 port = 8080
3130
32-[service.secrets]
31+[services.web.secrets]
3332 DATABASE_URL = "database-url"
3433 `
3534
@@ -41,6 +40,7 @@ func TestParse_ValidWeb(t *testing.T) {
4140 assert.Equal(t, "production", spec.Env["APP_ENV"])
4241 require.Len(t, spec.Services, 1)
4342 svc := spec.Services[0]
43+ assert.Equal(t, "web", svc.Name)
4444 assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image)
4545 assert.Equal(t, "blog.example.com", svc.HTTP[0].Host)
4646 assert.Equal(t, "auto", svc.HTTP[0].TLS)
@@ -50,8 +50,7 @@ func TestParse_ValidWeb(t *testing.T) {
5050 func TestParse_SharedNetwork(t *testing.T) {
5151 doc := `
5252 name = "media"
53-[[service]]
54-name = "web"
53+[services.web]
5554 image = "registry.example.com/media/web:1"
5655 [[network.shared]]
5756 network = "backend"
@@ -78,10 +77,9 @@ func TestParse_FileNameMismatchIsWarning(t *testing.T) {
7877 func TestParse_MissingTagNormalizesToLatest(t *testing.T) {
7978 doc := `
8079 name = "blog"
81-[[service]]
82-name = "web"
80+[services.web]
8381 image = "registry.example.com/blog/web"
84-[[service.http]]
82+[[services.web.http]]
8583 host = "blog.example.com"
8684 port = 8080
8785 `
@@ -95,30 +93,240 @@ func TestParse_UnknownFieldsRejected(t *testing.T) {
9593 name string
9694 doc string
9795 }{
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"},
102100 }
103101 for _, tc := range cases {
104102 t.Run(tc.name, func(t *testing.T) {
105103 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
106104 require.Error(t, err)
105+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
107106 })
108107 }
109108 }
110109
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) {
112237 doc := `
113238 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]
116273 image = "img:1"
117-[service.env]
118-FOO = "bar"
274+[services.web]
275+image = "img:2"
119276 `
120277 _, _, 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+ }
122330 }
123331
124332 func TestParse_Names(t *testing.T) {
@@ -127,18 +335,18 @@ func TestParse_Names(t *testing.T) {
127335 doc string
128336 wantErr string
129337 }{
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"},
133341 {"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"},
142350 }
143351 for _, tc := range cases {
144352 t.Run(tc.name, func(t *testing.T) {
@@ -154,13 +362,13 @@ func TestParse_Readiness(t *testing.T) {
154362 doc string
155363 wantErr string
156364 }{
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"},
164372 }
165373 for _, tc := range cases {
166374 t.Run(tc.name, func(t *testing.T) {
@@ -176,10 +384,10 @@ func TestParse_Interfaces(t *testing.T) {
176384 doc string
177385 wantErr string
178386 }{
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"},
183391 }
184392 for _, tc := range cases {
185393 t.Run(tc.name, func(t *testing.T) {
@@ -192,17 +400,16 @@ func TestParse_Interfaces(t *testing.T) {
192400 func TestParse_Backups(t *testing.T) {
193401 doc := `
194402 name = "shop"
195-[[service]]
196-name = "api"
403+[services.api]
197404 image = "registry.example.com/shop/api:2.0.0"
198-[[service.http]]
405+[[services.api.http]]
199406 host = "shop.example.com"
200407 port = 8080
201-[[service.database]]
408+[[services.api.database]]
202409 name = "main"
203410 type = "postgres"
204411 schedule = "daily"
205-[service.backup]
412+[services.api.backup]
206413 postgres = ["main"]
207414 `
208415 spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml")
@@ -225,23 +432,22 @@ postgres = ["main"]
225432 func TestParse_GameAndStagingExamples(t *testing.T) {
226433 game := `
227434 name = "rust"
228-[[service]]
229-name = "server"
435+[services.server]
230436 image = "registry.example.com/games/rust:2026.09"
231-[service.readiness]
437+[services.server.readiness]
232438 type = "log"
233439 path = "/data/logs/server.log"
234440 contains = "Server startup complete"
235441 timeout = "5m"
236-[[service.udp]]
442+[[services.server.udp]]
237443 entrypoint = "udp"
238444 port = 28015
239445 publish = "0.0.0.0:28015"
240-[[service.tcp]]
446+[[services.server.tcp]]
241447 entrypoint = "tcp"
242448 port = 28016
243449 publish = "127.0.0.1:28016"
244-[[service.volume]]
450+[[services.server.volume]]
245451 name = "rust-data"
246452 path = "/data"
247453 `
@@ -255,10 +461,9 @@ path = "/data"
255461 name = "blog-staging"
256462 [env]
257463 APP_ENV = "staging"
258-[[service]]
259-name = "web"
464+[services.web]
260465 image = "registry.example.com/blog/web:1.4.2-rc.1"
261-[[service.http]]
466+[[services.web.http]]
262467 host = "staging-blog.example.com"
263468 port = 8080
264469 `
@@ -278,14 +483,13 @@ func TestParse_NoFileEnvSecretReads(t *testing.T) {
278483 func TestParse_Binds(t *testing.T) {
279484 doc := `
280485 name = "blog"
281-[[service]]
282-name = "web"
486+[services.web]
283487 image = "registry.example.com/blog/web:1.4.2"
284-[[service.bind]]
488+[[services.web.bind]]
285489 name = "config"
286490 path = "/etc/app.conf"
287491 readonly = true
288-[[service.bind]]
492+[[services.web.bind]]
289493 name = "data"
290494 path = "/var/lib/app"
291495 `
@@ -299,10 +503,9 @@ path = "/var/lib/app"
299503 func TestParse_BindDestinationRejected(t *testing.T) {
300504 doc := `
301505 name = "blog"
302-[[service]]
303-name = "web"
506+[services.web]
304507 image = "registry.example.com/blog/web:1.4.2"
305-[[service.bind]]
508+[[services.web.bind]]
306509 name = "dev"
307510 path = "/dev"
308511 `
@@ -314,8 +517,7 @@ path = "/dev"
314517 func TestParse_Devices(t *testing.T) {
315518 doc := `
316519 name = "demo"
317-[[service]]
318-name = "worker"
520+[services.worker]
319521 image = "registry.example.com/demo/worker:1"
320522 devices = ["test_gpu"]
321523 `
@@ -328,8 +530,7 @@ devices = ["test_gpu"]
328530 func TestParse_DevicesWrongType(t *testing.T) {
329531 doc := `
330532 name = "demo"
331-[[service]]
332-name = "worker"
533+[services.worker]
333534 image = "registry.example.com/demo/worker:1"
334535 devices = "test_gpu"
335536 `
@@ -340,8 +541,7 @@ devices = "test_gpu"
340541 func TestParse_DevicesMalformedRejected(t *testing.T) {
341542 doc := `
342543 name = "demo"
343-[[service]]
344-name = "worker"
544+[services.worker]
345545 image = "registry.example.com/demo/worker:1"
346546 devices = ["Bad Name!"]
347547 `
@@ -352,8 +552,7 @@ devices = ["Bad Name!"]
352552 func TestParse_DevicesDuplicateRejected(t *testing.T) {
353553 doc := `
354554 name = "demo"
355-[[service]]
356-name = "worker"
555+[services.worker]
357556 image = "registry.example.com/demo/worker:1"
358557 devices = ["test_gpu", "test_gpu"]
359558 `
@@ -365,8 +564,7 @@ devices = ["test_gpu", "test_gpu"]
365564 func TestParse_UnknownGPUFieldRejected(t *testing.T) {
366565 doc := `
367566 name = "demo"
368-[[service]]
369-name = "worker"
567+[services.worker]
370568 image = "registry.example.com/demo/worker:1"
371569 gpus = "all"
372570 `
@@ -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 = 808029 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 = 808084 port = 8080
87 `85 `
@@ -95,30 +93,240 @@ func TestParse_UnknownFieldsRejected(t *testing.T) {
95 name string93 name string
96 doc string94 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 string335 doc string
128 wantErr string336 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 string362 doc string
155 wantErr string363 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 string384 doc string
177 wantErr string385 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 = 8080407 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 = 28015444 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 = 28016448 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 = 8080468 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 = true491 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) {
1717 t.Run("absent visibility stays public with auto tls", func(t *testing.T) {
1818 doc := `
1919 name = "blog"
20-[[service]]
21-name = "web"
20+[services.web]
2221 image = "registry.example.com/blog/web:1.0.0"
23-[[service.http]]
22+[[services.web.http]]
2423 host = "blog.example.com"
2524 port = 8080
2625 `
@@ -35,13 +34,12 @@ port = 8080
3534 t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) {
3635 doc := `
3736 name = "blog"
38-[[service]]
39-name = "api"
37+[services.api]
4038 image = "registry.example.com/blog/api:1.0.0"
41-[service.readiness]
39+[services.api.readiness]
4240 type = "http"
4341 path = "/healthz"
44-[[service.http]]
42+[[services.api.http]]
4543 visibility = "internal"
4644 port = 8080
4745 `
@@ -58,10 +56,9 @@ port = 8080
5856 t.Run("explicit public normalizes to auto tls", func(t *testing.T) {
5957 doc := `
6058 name = "blog"
61-[[service]]
62-name = "web"
59+[services.web]
6360 image = "registry.example.com/blog/web:1.0.0"
64-[[service.http]]
61+[[services.web.http]]
6562 host = "blog.example.com"
6663 port = 8080
6764 visibility = "public"
@@ -79,53 +76,48 @@ func TestParse_VisibilityRejections(t *testing.T) {
7976 tests := map[string]string{
8077 "internal forbids host": `
8178 name = "blog"
82-[[service]]
83-name = "api"
79+[services.api]
8480 image = "registry.example.com/blog/api:1.0.0"
85-[[service.http]]
81+[[services.api.http]]
8682 host = "blog.example.com"
8783 port = 8080
8884 visibility = "internal"
8985 `,
9086 "internal forbids tls": `
9187 name = "blog"
92-[[service]]
93-name = "api"
88+[services.api]
9489 image = "registry.example.com/blog/api:1.0.0"
95-[[service.http]]
90+[[services.api.http]]
9691 port = 8080
9792 visibility = "internal"
9893 tls = "auto"
9994 `,
10095 "internal forbids explicitly empty tls": `
10196 name = "blog"
102-[[service]]
103-name = "api"
97+[services.api]
10498 image = "registry.example.com/blog/api:1.0.0"
105-[[service.http]]
99+[[services.api.http]]
106100 port = 8080
107101 visibility = "internal"
108102 tls = ""
109103 `,
110104 "unknown visibility value": `
111105 name = "blog"
112-[[service]]
113-name = "web"
106+[services.web]
114107 image = "registry.example.com/blog/web:1.0.0"
115-[[service.http]]
108+[[services.web.http]]
116109 host = "blog.example.com"
117110 port = 8080
118111 visibility = "vpn"
119112 `,
120113 "internal port also published as tcp": `
121114 name = "blog"
122-[[service]]
123-name = "api"
115+[services.api]
124116 image = "registry.example.com/blog/api:1.0.0"
125-[[service.http]]
117+[[services.api.http]]
126118 port = 8080
127119 visibility = "internal"
128-[[service.tcp]]
120+[[services.api.tcp]]
129121 port = 8080
130122 `,
131123 }
@@ -143,10 +135,9 @@ port = 8080
143135 func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) {
144136 doc := `
145137 name = "blog"
146-[[service]]
147-name = "web"
138+[services.web]
148139 image = "registry.example.com/blog/web:1.0.0"
149-[[service.http]]
140+[[services.web.http]]
150141 host = "blog.example.com"
151142 port = 8080
152143 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 = 808024 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 = 808044 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 = 808063 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 = 808083 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 = 808091 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 = 8080100 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 = 8080110 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 = 8080118 port = 8080
127 visibility = "internal"119 visibility = "internal"
128-[[service.tcp]]120+[[services.api.tcp]]
129 port = 8080121 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 = 8080142 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 (
2626 // surface (one service, one HTTP host).
2727 const validLocalAppManifest = `
2828 name = "blog"
29-[[service]]
30-name = "web"
29+[services.web]
3130 image = "img:1"
32-[[service.http]]
31+[[services.web.http]]
3332 host = "blog.example.com"
3433 port = 8080
3534 `
@@ -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 = 808033 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
8181
8282 const validAppManifest = `
8383 name = "blog"
84-[[service]]
85-name = "web"
84+[services.web]
8685 image = "img:1"
87-[[service.http]]
86+[[services.web.http]]
8887 host = "blog.example.com"
8988 port = 8080
9089 `
@@ -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 = 808088 port = 8080
90 `89 `
M internal/app/run.go +7 -5
@@ -129,7 +129,7 @@ type Config struct {
129129 } `mapstructure:"apps"`
130130
131131 // 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]].
133133 AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"`
134134
135135 // AppDevices declares administrative device grants keyed by stable
@@ -463,15 +463,17 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
463463 // retiredAppConfigKeys are pre-v3 application keys removed with the
464464 // declarative-apps cutover. Presence fails boot/reload closed with a
465465 // 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.
466469 var retiredAppConfigKeys = []struct {
467470 key string
468471 hint string
469472 }{
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"},
472475 {"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"},
475477 {"auto", "feature removed; declare explicit interfaces"},
476478 {"auto_route", "feature removed; declare explicit interfaces"},
477479 {"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 mount131 // 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 stable135 // 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 the463 // retiredAppConfigKeys are pre-v3 application keys removed with the
464 // declarative-apps cutover. Presence fails boot/reload closed with a464 // 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 string470 key string
468 hint string471 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close