detached d676137

fix(cli): explain restart_unsafe in apps status (#10)

Closes #5

## Summary

`gordon apps status` and `apps show` replace the bare `restart_unsafe` token with a short `single-writer` mark per service and one legend line under the list:

```
single-writer: has volumes or binds; recovery never restarts a replaced container over its successor (informational, no action needed)
```

- The mark appears for services with volumes or binds (`restart_unsafe: true` in JSON, unchanged).
- It explains the only behavior specific to these services: boot and periodic recovery never restart a replaced generation on top of its replacement. Stop-before-start applies to every service and is linked from the docs.
- Deploy output no longer renders the flag, which the server never sets on deploy results.
- Docs: `apps status` and `apps show` sections, plus a pointer in `config/apps.md`.

## Checks

- `go test ./...`
- `golangci-lint run ./...`

Reviewed-on: https://git.bnema.dev/bnema/gordon/pulls/10
commit
d67613744e84c17da1c3bc60d366ccfa49992051
author
brice · · 2026-10-02 13:35 UTC
committer
bnema · · 2026-10-02 13:35 UTC
parent
48e0691
signature
unsigned
refs
tag: v3.1.0
4 files · +54 -9
M docs/cli/apps.md +10 -1
@@ -97,7 +97,8 @@ gordon apps show APP [--json]
9797 ```
9898
9999 Shows desired revision and acceptance status, pending state, per-service
100-effective revision, container, digest, and restart safety, the resources the
100+effective revision, container, digest, and `single-writer` mark (see
101+[apps status](#gordon-apps-status)), the resources the
101102 app owns (volumes, secret paths, image references — never secret values),
102103 stopped intent, and the last operation with its outcome.
103104
@@ -352,6 +353,14 @@ gordon apps status APP [--json]
352353
353354 Shows effective vs observed state per service.
354355
356+A service line ends with `single-writer` when the service has volumes or binds
357+(`restart_unsafe: true` in `--json`), and a legend line follows the list. The
358+mark is informational and needs no action. When such a service is replaced,
359+Gordon durably blocks boot and periodic recovery from restarting the old
360+container, so it can never write to the same data as its replacement. Every
361+service is replaced stop-before-start; see [Updates](../concepts.md#updates).
362+`apps show` uses the same mark.
363+
355364 ---
356365
357366 ## gordon apps logs
@@ -97,7 +97,8 @@ gordon apps show APP [--json]
97 ```97 ```
98 98
99 Shows desired revision and acceptance status, pending state, per-service99 Shows desired revision and acceptance status, pending state, per-service
100-effective revision, container, digest, and restart safety, the resources the100+effective revision, container, digest, and `single-writer` mark (see
101+[apps status](#gordon-apps-status)), the resources the
101 app owns (volumes, secret paths, image references — never secret values),102 app owns (volumes, secret paths, image references — never secret values),
102 stopped intent, and the last operation with its outcome.103 stopped intent, and the last operation with its outcome.
103 104
@@ -352,6 +353,14 @@ gordon apps status APP [--json]
352 353
353 Shows effective vs observed state per service.354 Shows effective vs observed state per service.
354 355
356+A service line ends with `single-writer` when the service has volumes or binds
357+(`restart_unsafe: true` in `--json`), and a legend line follows the list. The
358+mark is informational and needs no action. When such a service is replaced,
359+Gordon durably blocks boot and periodic recovery from restarting the old
360+container, so it can never write to the same data as its replacement. Every
361+service is replaced stop-before-start; see [Updates](../concepts.md#updates).
362+`apps show` uses the same mark.
363+
355 ---364 ---
356 365
357 ## gordon apps logs366 ## gordon apps logs
M docs/config/apps.md +1 -1
@@ -121,7 +121,7 @@ Image registry names and digest syntax are validated during manifest apply, reso
121121 - 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.
122122 - `[[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.
123123 - `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.
124-- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once.
124+- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status).
125125 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
126126 - `[[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`).
127127 - `[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.
@@ -121,7 +121,7 @@ Image registry names and digest syntax are validated during manifest apply, reso
121 - 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.121 - 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.
122 - `[[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.122 - `[[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.
123 - `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.123 - `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.
124-- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once.124+- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status).
125 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.125 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
126 - `[[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`).126 - `[[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`).
127 - `[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.127 - `[services.<name>.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change.
M internal/adapters/in/cli/apps.go +12 -5
@@ -450,6 +450,11 @@ func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Wri
450450 return nil
451451 }
452452
453+// singleWriterLegend explains the single-writer mark (restart_unsafe in
454+// JSON) once per listing: recovery never restarts a replaced generation of
455+// a volume- or bind-owning service on top of its replacement.
456+const singleWriterLegend = "single-writer: has volumes or binds; recovery never restarts a replaced container over its successor (informational, no action needed)"
457+
453458 // renderActiveServices renders per-service effective state, sorted by name.
454459 // Only ids and digests appear — never secret values.
455460 func renderActiveServices(active dto.AppActiveDTO) []string {
@@ -458,12 +463,13 @@ func renderActiveServices(active dto.AppActiveDTO) []string {
458463 names = append(names, name)
459464 }
460465 sort.Strings(names)
461- lines := make([]string, 0, len(names)+1)
466+ lines := make([]string, 0, len(names)+2)
462467 converged := "converged"
463468 if !active.Converged {
464469 converged = "diverged"
465470 }
466471 lines = append(lines, cliRenderMeta("active:", converged))
472+ singleWriter := false
467473 for _, name := range names {
468474 svc := active.Services[name]
469475 detail := svc.EffectiveRevision
@@ -471,10 +477,14 @@ func renderActiveServices(active dto.AppActiveDTO) []string {
471477 detail += " container=" + svc.Container
472478 }
473479 if svc.RestartUnsafe {
474- detail += " restart_unsafe"
480+ detail += " single-writer"
481+ singleWriter = true
475482 }
476483 lines = append(lines, cliRenderMeta(" "+name+":", detail))
477484 }
485+ if singleWriter {
486+ lines = append(lines, cliRenderMuted(" "+singleWriterLegend))
487+ }
478488 return lines
479489 }
480490
@@ -1004,9 +1014,6 @@ func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
10041014 if svc.Result == domain.AppServiceUnchanged {
10051015 detail += " (already running this image, config, and secrets)"
10061016 }
1007- if svc.RestartUnsafe {
1008- detail += " restart_unsafe"
1009- }
10101017 if svc.Error != "" {
10111018 detail += ": " + svc.Error
10121019 }
@@ -450,6 +450,11 @@ func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Wri
450 return nil450 return nil
451 }451 }
452 452
453+// singleWriterLegend explains the single-writer mark (restart_unsafe in
454+// JSON) once per listing: recovery never restarts a replaced generation of
455+// a volume- or bind-owning service on top of its replacement.
456+const singleWriterLegend = "single-writer: has volumes or binds; recovery never restarts a replaced container over its successor (informational, no action needed)"
457+
453 // renderActiveServices renders per-service effective state, sorted by name.458 // renderActiveServices renders per-service effective state, sorted by name.
454 // Only ids and digests appear — never secret values.459 // Only ids and digests appear — never secret values.
455 func renderActiveServices(active dto.AppActiveDTO) []string {460 func renderActiveServices(active dto.AppActiveDTO) []string {
@@ -458,12 +463,13 @@ func renderActiveServices(active dto.AppActiveDTO) []string {
458 names = append(names, name)463 names = append(names, name)
459 }464 }
460 sort.Strings(names)465 sort.Strings(names)
461- lines := make([]string, 0, len(names)+1)466+ lines := make([]string, 0, len(names)+2)
462 converged := "converged"467 converged := "converged"
463 if !active.Converged {468 if !active.Converged {
464 converged = "diverged"469 converged = "diverged"
465 }470 }
466 lines = append(lines, cliRenderMeta("active:", converged))471 lines = append(lines, cliRenderMeta("active:", converged))
472+ singleWriter := false
467 for _, name := range names {473 for _, name := range names {
468 svc := active.Services[name]474 svc := active.Services[name]
469 detail := svc.EffectiveRevision475 detail := svc.EffectiveRevision
@@ -471,10 +477,14 @@ func renderActiveServices(active dto.AppActiveDTO) []string {
471 detail += " container=" + svc.Container477 detail += " container=" + svc.Container
472 }478 }
473 if svc.RestartUnsafe {479 if svc.RestartUnsafe {
474- detail += " restart_unsafe"480+ detail += " single-writer"
481+ singleWriter = true
475 }482 }
476 lines = append(lines, cliRenderMeta(" "+name+":", detail))483 lines = append(lines, cliRenderMeta(" "+name+":", detail))
477 }484 }
485+ if singleWriter {
486+ lines = append(lines, cliRenderMuted(" "+singleWriterLegend))
487+ }
478 return lines488 return lines
479 }489 }
480 490
@@ -1004,9 +1014,6 @@ func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
1004 if svc.Result == domain.AppServiceUnchanged {1014 if svc.Result == domain.AppServiceUnchanged {
1005 detail += " (already running this image, config, and secrets)"1015 detail += " (already running this image, config, and secrets)"
1006 }1016 }
1007- if svc.RestartUnsafe {
1008- detail += " restart_unsafe"
1009- }
1010 if svc.Error != "" {1017 if svc.Error != "" {
1011 detail += ": " + svc.Error1018 detail += ": " + svc.Error
1012 }1019 }
M internal/adapters/in/cli/apps_test.go +31 -2
@@ -128,6 +128,35 @@ func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
128128 assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
129129 }
130130
131+func TestRenderActiveServices_SingleWriterLegend(t *testing.T) {
132+ for _, tc := range []struct {
133+ name string
134+ flagged []string
135+ }{
136+ {"none", nil},
137+ {"one", []string{"db"}},
138+ {"two", []string{"db", "web"}},
139+ } {
140+ t.Run(tc.name, func(t *testing.T) {
141+ active := dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
142+ "db": {EffectiveRevision: "rev-1"}, "web": {EffectiveRevision: "rev-1"}, "worker": {EffectiveRevision: "rev-1"},
143+ }}
144+ for _, name := range tc.flagged {
145+ svc := active.Services[name]
146+ svc.RestartUnsafe = true
147+ active.Services[name] = svc
148+ }
149+ text := strings.Join(renderActiveServices(active), "\n")
150+ assert.Equal(t, len(tc.flagged), strings.Count(text, "rev-1 single-writer"))
151+ legends := 0
152+ if len(tc.flagged) > 0 {
153+ legends = 1
154+ }
155+ assert.Equal(t, legends, strings.Count(text, singleWriterLegend))
156+ })
157+ }
158+}
159+
131160 func TestRunAppsShow_JSONParity(t *testing.T) {
132161 want := &dto.AppShowResponse{
133162 App: "blog",
@@ -148,7 +177,8 @@ func TestRunAppsShow_JSONParity(t *testing.T) {
148177
149178 out.Reset()
150179 require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
151- assert.Contains(t, out.String(), "restart_unsafe", "text shows WHY recovery is blocked")
180+ assert.Contains(t, out.String(), "single-writer: has volumes or binds", "text explains the single-writer mark")
181+ assert.NotContains(t, out.String(), "restart_unsafe", "text does not print the bare JSON token")
152182 }
153183
154184 func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
@@ -290,7 +320,6 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
290320 require.NoError(t, renderAppDeployResponse(&out, resp))
291321 text := out.String()
292322 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
293- assert.Contains(t, text, "restart_unsafe")
294323 assert.Contains(t, text, "runner: removed\n")
295324 assert.Contains(t, text, "cleanup:")
296325 assert.Contains(t, text, "blog-db")
@@ -128,6 +128,35 @@ func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
128 assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))128 assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
129 }129 }
130 130
131+func TestRenderActiveServices_SingleWriterLegend(t *testing.T) {
132+ for _, tc := range []struct {
133+ name string
134+ flagged []string
135+ }{
136+ {"none", nil},
137+ {"one", []string{"db"}},
138+ {"two", []string{"db", "web"}},
139+ } {
140+ t.Run(tc.name, func(t *testing.T) {
141+ active := dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
142+ "db": {EffectiveRevision: "rev-1"}, "web": {EffectiveRevision: "rev-1"}, "worker": {EffectiveRevision: "rev-1"},
143+ }}
144+ for _, name := range tc.flagged {
145+ svc := active.Services[name]
146+ svc.RestartUnsafe = true
147+ active.Services[name] = svc
148+ }
149+ text := strings.Join(renderActiveServices(active), "\n")
150+ assert.Equal(t, len(tc.flagged), strings.Count(text, "rev-1 single-writer"))
151+ legends := 0
152+ if len(tc.flagged) > 0 {
153+ legends = 1
154+ }
155+ assert.Equal(t, legends, strings.Count(text, singleWriterLegend))
156+ })
157+ }
158+}
159+
131 func TestRunAppsShow_JSONParity(t *testing.T) {160 func TestRunAppsShow_JSONParity(t *testing.T) {
132 want := &dto.AppShowResponse{161 want := &dto.AppShowResponse{
133 App: "blog",162 App: "blog",
@@ -148,7 +177,8 @@ func TestRunAppsShow_JSONParity(t *testing.T) {
148 177
149 out.Reset()178 out.Reset()
150 require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))179 require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
151- assert.Contains(t, out.String(), "restart_unsafe", "text shows WHY recovery is blocked")180+ assert.Contains(t, out.String(), "single-writer: has volumes or binds", "text explains the single-writer mark")
181+ assert.NotContains(t, out.String(), "restart_unsafe", "text does not print the bare JSON token")
152 }182 }
153 183
154 func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {184 func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
@@ -290,7 +320,6 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
290 require.NoError(t, renderAppDeployResponse(&out, resp))320 require.NoError(t, renderAppDeployResponse(&out, resp))
291 text := out.String()321 text := out.String()
292 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))322 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
293- assert.Contains(t, text, "restart_unsafe")
294 assert.Contains(t, text, "runner: removed\n")323 assert.Contains(t, text, "runner: removed\n")
295 assert.Contains(t, text, "cleanup:")324 assert.Contains(t, text, "cleanup:")
296 assert.Contains(t, text, "blog-db")325 assert.Contains(t, text, "blog-db")

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