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] | |||
| 97 | 97 | ``` | |
| 98 | 98 | ||
| 99 | 99 | 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 | |
| 101 | 102 | app owns (volumes, secret paths, image references — never secret values), | |
| 102 | 103 | stopped intent, and the last operation with its outcome. | |
| 103 | 104 | ||
| @@ -352,6 +353,14 @@ gordon apps status APP [--json] | |||
| 352 | 353 | ||
| 353 | 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 | 366 | ## 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-service | 99 | 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 | |||
| 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 logs | 366 | ## 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 | |||
| 121 | 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 | 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 | 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 | 125 | - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data. | |
| 126 | 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 | 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. | |
| @@ -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 | |||
| 450 | 450 | 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 | 458 | // renderActiveServices renders per-service effective state, sorted by name. | |
| 454 | 459 | // Only ids and digests appear — never secret values. | |
| 455 | 460 | func renderActiveServices(active dto.AppActiveDTO) []string { | |
| @@ -458,12 +463,13 @@ func renderActiveServices(active dto.AppActiveDTO) []string { | |||
| 458 | 463 | names = append(names, name) | |
| 459 | 464 | } | |
| 460 | 465 | sort.Strings(names) | |
| 461 | - | lines := make([]string, 0, len(names)+1) | |
| 466 | + | lines := make([]string, 0, len(names)+2) | |
| 462 | 467 | converged := "converged" | |
| 463 | 468 | if !active.Converged { | |
| 464 | 469 | converged = "diverged" | |
| 465 | 470 | } | |
| 466 | 471 | lines = append(lines, cliRenderMeta("active:", converged)) | |
| 472 | + | singleWriter := false | |
| 467 | 473 | for _, name := range names { | |
| 468 | 474 | svc := active.Services[name] | |
| 469 | 475 | detail := svc.EffectiveRevision | |
| @@ -471,10 +477,14 @@ func renderActiveServices(active dto.AppActiveDTO) []string { | |||
| 471 | 477 | detail += " container=" + svc.Container | |
| 472 | 478 | } | |
| 473 | 479 | if svc.RestartUnsafe { | |
| 474 | - | detail += " restart_unsafe" | |
| 480 | + | detail += " single-writer" | |
| 481 | + | singleWriter = true | |
| 475 | 482 | } | |
| 476 | 483 | lines = append(lines, cliRenderMeta(" "+name+":", detail)) | |
| 477 | 484 | } | |
| 485 | + | if singleWriter { | |
| 486 | + | lines = append(lines, cliRenderMuted(" "+singleWriterLegend)) | |
| 487 | + | } | |
| 478 | 488 | return lines | |
| 479 | 489 | } | |
| 480 | 490 | ||
| @@ -1004,9 +1014,6 @@ func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error { | |||
| 1004 | 1014 | if svc.Result == domain.AppServiceUnchanged { | |
| 1005 | 1015 | detail += " (already running this image, config, and secrets)" | |
| 1006 | 1016 | } | |
| 1007 | - | if svc.RestartUnsafe { | |
| 1008 | - | detail += " restart_unsafe" | |
| 1009 | - | } | |
| 1010 | 1017 | if svc.Error != "" { | |
| 1011 | 1018 | detail += ": " + svc.Error | |
| 1012 | 1019 | } | |
| @@ -450,6 +450,11 @@ func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Wri | |||||
| 450 | return nil | 450 | 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.EffectiveRevision | 475 | detail := svc.EffectiveRevision | ||
| @@ -471,10 +477,14 @@ func renderActiveServices(active dto.AppActiveDTO) []string { | |||||
| 471 | detail += " container=" + svc.Container | 477 | 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 lines | 488 | 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.Error | 1018 | 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) { | |||
| 128 | 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 | 160 | func TestRunAppsShow_JSONParity(t *testing.T) { | |
| 132 | 161 | want := &dto.AppShowResponse{ | |
| 133 | 162 | App: "blog", | |
| @@ -148,7 +177,8 @@ func TestRunAppsShow_JSONParity(t *testing.T) { | |||
| 148 | 177 | ||
| 149 | 178 | out.Reset() | |
| 150 | 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 | 184 | func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) { | |
| @@ -290,7 +320,6 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) { | |||
| 290 | 320 | require.NoError(t, renderAppDeployResponse(&out, resp)) | |
| 291 | 321 | text := out.String() | |
| 292 | 322 | assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:")) | |
| 293 | - | assert.Contains(t, text, "restart_unsafe") | |
| 294 | 323 | assert.Contains(t, text, "runner: removed\n") | |
| 295 | 324 | assert.Contains(t, text, "cleanup:") | |
| 296 | 325 | 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") | ||