detached 3e8cda9

feat(apps): require --service or --all to deploy/restart multi-service apps (#292)

* feat(apps): require --service or --all to deploy/restart multi-service apps

App-wide deploy and restart of an app with several services now fail before any claim with ErrAppServiceScope (HTTP 400 service-scope-required), listing the sorted services. The admin API accepts 'all' (deploy body, restart query) and the CLI adds a mutually exclusive --all flag. apps apply --deploy keeps deploying every service.

Closes #290

* fix(apps): scope check counts the revision to be deployed

An unscoped deploy of an older revision slipped past requireServiceScope
when the desired head and ACTIVE held a single service but the selected
revision declared several: StartDeploy would then replace every service
in that revision. Resolve the revision to activate first (explicit
revision, else desired head) and count its services before claiming.
Wrap the LoadDesired/LoadActive/LoadRevision scope reads with app
context while preserving the underlying store errors.
commit
3e8cda9d250a3621ca16c6a99fc7f6cefac976b2
author
bnema · · 2026-09-25 04:50 UTC
committer
GitHub · · 2026-09-25 04:50 UTC
parent
7e66aa6
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +432 -88
M docs/cli/apps.md +18 -4
@@ -246,10 +246,23 @@ Deletes the registered value for `KEY`.
246246 ## gordon apps deploy
247247
248248 ```bash
249-gordon apps deploy APP [--revision REV] [--service SVC] [--json]
249+gordon apps deploy APP [--revision REV] [--service SVC | --all] [--json]
250250 ```
251251
252-Activates a revision (default: desired head). Fail-fast across services:
252+Activates a revision (default: desired head). An app with several services
253+needs `--service NAME` (one service) or `--all` (every service); without
254+either, the command fails before any change and lists the services. A
255+single-service app needs neither. `apps apply --deploy` always deploys every
256+service.
257+
258+| Flag | Description |
259+|------|-------------|
260+| `--revision REV` | Revision to activate (default: desired head) |
261+| `--service NAME` | Deploy one service only |
262+| `--all` | Deploy every service |
263+| `--json` | Output as JSON |
264+
265+Fail-fast across services:
253266 the first failure stops the deploy, successful services are preserved,
254267 later services stay unchanged.
255268
@@ -266,10 +279,11 @@ local polling (the daemon-side operation keeps running) and prints the
266279 ## gordon apps restart
267280
268281 ```bash
269-gordon apps restart APP [--service SVC] [--json]
282+gordon apps restart APP [--service SVC | --all] [--json]
270283 ```
271284
272-Restarts from pinned digests without re-resolution.
285+Restarts from pinned digests without re-resolution. Like `deploy`, an app
286+with several services needs `--service NAME` or `--all`.
273287
274288 ---
275289
@@ -246,10 +246,23 @@ Deletes the registered value for `KEY`.
246 ## gordon apps deploy246 ## gordon apps deploy
247 247
248 ```bash248 ```bash
249-gordon apps deploy APP [--revision REV] [--service SVC] [--json]249+gordon apps deploy APP [--revision REV] [--service SVC | --all] [--json]
250 ```250 ```
251 251
252-Activates a revision (default: desired head). Fail-fast across services:252+Activates a revision (default: desired head). An app with several services
253+needs `--service NAME` (one service) or `--all` (every service); without
254+either, the command fails before any change and lists the services. A
255+single-service app needs neither. `apps apply --deploy` always deploys every
256+service.
257+
258+| Flag | Description |
259+|------|-------------|
260+| `--revision REV` | Revision to activate (default: desired head) |
261+| `--service NAME` | Deploy one service only |
262+| `--all` | Deploy every service |
263+| `--json` | Output as JSON |
264+
265+Fail-fast across services:
253 the first failure stops the deploy, successful services are preserved,266 the first failure stops the deploy, successful services are preserved,
254 later services stay unchanged.267 later services stay unchanged.
255 268
@@ -266,10 +279,11 @@ local polling (the daemon-side operation keeps running) and prints the
266 ## gordon apps restart279 ## gordon apps restart
267 280
268 ```bash281 ```bash
269-gordon apps restart APP [--service SVC] [--json]282+gordon apps restart APP [--service SVC | --all] [--json]
270 ```283 ```
271 284
272-Restarts from pinned digests without re-resolution.285+Restarts from pinned digests without re-resolution. Like `deploy`, an app
286+with several services needs `--service NAME` or `--all`.
273 287
274 ---288 ---
275 289
M internal/adapters/dto/admin_apps.go +2 -0
@@ -47,6 +47,8 @@ type AppApplyResponse struct {
4747 type AppDeployRequest struct {
4848 Revision string `json:"revision,omitempty"`
4949 Service string `json:"service,omitempty"`
50+ // All confirms an app-wide deploy of a multi-service app.
51+ All bool `json:"all,omitempty"`
5052 }
5153
5254 // AppServiceResultDTO is one service's terminal deployment result.
@@ -47,6 +47,8 @@ type AppApplyResponse struct {
47 type AppDeployRequest struct {47 type AppDeployRequest struct {
48 Revision string `json:"revision,omitempty"`48 Revision string `json:"revision,omitempty"`
49 Service string `json:"service,omitempty"`49 Service string `json:"service,omitempty"`
50+ // All confirms an app-wide deploy of a multi-service app.
51+ All bool `json:"all,omitempty"`
50 }52 }
51 53
52 // AppServiceResultDTO is one service's terminal deployment result.54 // AppServiceResultDTO is one service's terminal deployment result.
M internal/adapters/in/cli/apps.go +15 -8
@@ -219,7 +219,7 @@ type appApplyDeployDocument struct {
219219 // that the daemon-side operation keeps running. A terminal partial/failed
220220 // outcome exits nonzero without hiding the successful apply.
221221 func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
222- deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})
222+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
223223 if err != nil {
224224 if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {
225225 return applySucceededError(apply, conflictErr)
@@ -241,7 +241,7 @@ func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppA
241241 // running document is emitted, and progress, transient warnings, and Ctrl-C
242242 // resume guidance go to errOut so stdout stays machine-readable.
243243 func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
244- deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})
244+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
245245 if err != nil {
246246 var conflict *remote.AppOpConflictError
247247 if errors.As(err, &conflict) {
@@ -770,6 +770,7 @@ func readSecretStdin(in io.Reader) ([]string, error) {
770770 func newAppDeployCmd() *cobra.Command {
771771 var revision string
772772 var service string
773+ var all bool
773774 var jsonOut bool
774775 cmd := &cobra.Command{
775776 Use: "deploy APP",
@@ -782,11 +783,14 @@ func newAppDeployCmd() *cobra.Command {
782783 }
783784 defer handle.close()
784785 plane := handle.plane
785- return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
786+ req := dto.AppDeployRequest{Revision: revision, Service: service, All: all}
787+ return runAppDeploy(cmd.Context(), plane, args[0], req, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
786788 },
787789 }
788790 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
789791 cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
792+ cmd.Flags().BoolVar(&all, "all", false, "Deploy every service (required for multi-service apps without --service)")
793+ cmd.MarkFlagsMutuallyExclusive("service", "all")
790794 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
791795 return cmd
792796 }
@@ -794,8 +798,8 @@ func newAppDeployCmd() *cobra.Command {
794798 // runAppDeploy issues one deploy mutation and, when the daemon answers 202
795799 // with a running journal, polls the existing by-key endpoint to terminal
796800 // rather than reissuing the mutation or hiding the outcome.
797-func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out, errOut io.Writer, jsonOut bool) error {
798- resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})
801+func runAppDeploy(ctx context.Context, plane ControlPlane, app string, req dto.AppDeployRequest, out, errOut io.Writer, jsonOut bool) error {
802+ resp, key, err := plane.DeployApp(ctx, app, req)
799803 if err != nil {
800804 if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
801805 return conflictErr
@@ -814,6 +818,7 @@ func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, servic
814818 // newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
815819 func newAppRestartCmd() *cobra.Command {
816820 var service string
821+ var all bool
817822 var jsonOut bool
818823 cmd := &cobra.Command{
819824 Use: "restart APP",
@@ -826,16 +831,18 @@ func newAppRestartCmd() *cobra.Command {
826831 }
827832 defer handle.close()
828833 plane := handle.plane
829- return runAppRestart(cmd.Context(), plane, args[0], service, cmd.OutOrStdout(), jsonOut)
834+ return runAppRestart(cmd.Context(), plane, args[0], service, all, cmd.OutOrStdout(), jsonOut)
830835 },
831836 }
832837 cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
838+ cmd.Flags().BoolVar(&all, "all", false, "Restart every service (required for multi-service apps without --service)")
839+ cmd.MarkFlagsMutuallyExclusive("service", "all")
833840 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
834841 return cmd
835842 }
836843
837-func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, out io.Writer, jsonOut bool) error {
838- resp, key, err := plane.RestartApp(ctx, app, service)
844+func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, all bool, out io.Writer, jsonOut bool) error {
845+ resp, key, err := plane.RestartApp(ctx, app, service, all)
839846 if err != nil {
840847 if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
841848 return conflictErr
@@ -219,7 +219,7 @@ type appApplyDeployDocument struct {
219 // that the daemon-side operation keeps running. A terminal partial/failed219 // that the daemon-side operation keeps running. A terminal partial/failed
220 // outcome exits nonzero without hiding the successful apply.220 // outcome exits nonzero without hiding the successful apply.
221 func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {221 func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
222- deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})222+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
223 if err != nil {223 if err != nil {
224 if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {224 if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {
225 return applySucceededError(apply, conflictErr)225 return applySucceededError(apply, conflictErr)
@@ -241,7 +241,7 @@ func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppA
241 // running document is emitted, and progress, transient warnings, and Ctrl-C241 // running document is emitted, and progress, transient warnings, and Ctrl-C
242 // resume guidance go to errOut so stdout stays machine-readable.242 // resume guidance go to errOut so stdout stays machine-readable.
243 func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {243 func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
244- deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})244+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
245 if err != nil {245 if err != nil {
246 var conflict *remote.AppOpConflictError246 var conflict *remote.AppOpConflictError
247 if errors.As(err, &conflict) {247 if errors.As(err, &conflict) {
@@ -770,6 +770,7 @@ func readSecretStdin(in io.Reader) ([]string, error) {
770 func newAppDeployCmd() *cobra.Command {770 func newAppDeployCmd() *cobra.Command {
771 var revision string771 var revision string
772 var service string772 var service string
773+ var all bool
773 var jsonOut bool774 var jsonOut bool
774 cmd := &cobra.Command{775 cmd := &cobra.Command{
775 Use: "deploy APP",776 Use: "deploy APP",
@@ -782,11 +783,14 @@ func newAppDeployCmd() *cobra.Command {
782 }783 }
783 defer handle.close()784 defer handle.close()
784 plane := handle.plane785 plane := handle.plane
785- return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)786+ req := dto.AppDeployRequest{Revision: revision, Service: service, All: all}
787+ return runAppDeploy(cmd.Context(), plane, args[0], req, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
786 },788 },
787 }789 }
788 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")790 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
789 cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")791 cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
792+ cmd.Flags().BoolVar(&all, "all", false, "Deploy every service (required for multi-service apps without --service)")
793+ cmd.MarkFlagsMutuallyExclusive("service", "all")
790 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")794 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
791 return cmd795 return cmd
792 }796 }
@@ -794,8 +798,8 @@ func newAppDeployCmd() *cobra.Command {
794 // runAppDeploy issues one deploy mutation and, when the daemon answers 202798 // runAppDeploy issues one deploy mutation and, when the daemon answers 202
795 // with a running journal, polls the existing by-key endpoint to terminal799 // with a running journal, polls the existing by-key endpoint to terminal
796 // rather than reissuing the mutation or hiding the outcome.800 // rather than reissuing the mutation or hiding the outcome.
797-func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out, errOut io.Writer, jsonOut bool) error {801+func runAppDeploy(ctx context.Context, plane ControlPlane, app string, req dto.AppDeployRequest, out, errOut io.Writer, jsonOut bool) error {
798- resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})802+ resp, key, err := plane.DeployApp(ctx, app, req)
799 if err != nil {803 if err != nil {
800 if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {804 if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
801 return conflictErr805 return conflictErr
@@ -814,6 +818,7 @@ func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, servic
814 // newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.818 // newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
815 func newAppRestartCmd() *cobra.Command {819 func newAppRestartCmd() *cobra.Command {
816 var service string820 var service string
821+ var all bool
817 var jsonOut bool822 var jsonOut bool
818 cmd := &cobra.Command{823 cmd := &cobra.Command{
819 Use: "restart APP",824 Use: "restart APP",
@@ -826,16 +831,18 @@ func newAppRestartCmd() *cobra.Command {
826 }831 }
827 defer handle.close()832 defer handle.close()
828 plane := handle.plane833 plane := handle.plane
829- return runAppRestart(cmd.Context(), plane, args[0], service, cmd.OutOrStdout(), jsonOut)834+ return runAppRestart(cmd.Context(), plane, args[0], service, all, cmd.OutOrStdout(), jsonOut)
830 },835 },
831 }836 }
832 cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")837 cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
838+ cmd.Flags().BoolVar(&all, "all", false, "Restart every service (required for multi-service apps without --service)")
839+ cmd.MarkFlagsMutuallyExclusive("service", "all")
833 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")840 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
834 return cmd841 return cmd
835 }842 }
836 843
837-func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, out io.Writer, jsonOut bool) error {844+func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, all bool, out io.Writer, jsonOut bool) error {
838- resp, key, err := plane.RestartApp(ctx, app, service)845+ resp, key, err := plane.RestartApp(ctx, app, service, all)
839 if err != nil {846 if err != nil {
840 if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {847 if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
841 return conflictErr848 return conflictErr
M internal/adapters/in/cli/apps_test.go +6 -6
@@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204204 plane := appPlane(t)
205205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206206 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
207- err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, &bytes.Buffer{}, false)
207+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false)
208208 require.Error(t, err)
209209 assert.Contains(t, err.Error(), "outcome-unknown")
210210 assert.Contains(t, err.Error(), "key-abc")
@@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
223223 },
224224 }).Once()
225225 var out bytes.Buffer
226- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, false)
226+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false)
227227 require.Error(t, err, "conflict must retain failure semantics")
228228 text := out.String()
229229 assert.Contains(t, text, "web:")
@@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
246246 },
247247 }).Once()
248248 var out bytes.Buffer
249- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, true)
249+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true)
250250 require.Error(t, err)
251251 var got dto.AppDeployResponse
252252 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
@@ -440,16 +440,16 @@ func TestRunAppsSecretsDelete(t *testing.T) {
440440 func TestRunAppRestart_AndLifecycle(t *testing.T) {
441441 resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
442442 plane := appPlane(t)
443- plane.EXPECT().RestartApp(mock.Anything, "blog", "").Return(resp, "key-lc", nil).Times(2)
443+ plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2)
444444 plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
445445 plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
446446 plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
447447 var out bytes.Buffer
448- require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, false))
448+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false))
449449 assert.Contains(t, out.String(), "success")
450450
451451 out.Reset()
452- require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, true))
452+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true))
453453 var got dto.AppDeployResponse
454454 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
455455 assert.Equal(t, *resp, got)
@@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204 plane := appPlane(t)204 plane := appPlane(t)
205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()206 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
207- err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, &bytes.Buffer{}, false)207+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false)
208 require.Error(t, err)208 require.Error(t, err)
209 assert.Contains(t, err.Error(), "outcome-unknown")209 assert.Contains(t, err.Error(), "outcome-unknown")
210 assert.Contains(t, err.Error(), "key-abc")210 assert.Contains(t, err.Error(), "key-abc")
@@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
223 },223 },
224 }).Once()224 }).Once()
225 var out bytes.Buffer225 var out bytes.Buffer
226- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, false)226+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false)
227 require.Error(t, err, "conflict must retain failure semantics")227 require.Error(t, err, "conflict must retain failure semantics")
228 text := out.String()228 text := out.String()
229 assert.Contains(t, text, "web:")229 assert.Contains(t, text, "web:")
@@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
246 },246 },
247 }).Once()247 }).Once()
248 var out bytes.Buffer248 var out bytes.Buffer
249- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, true)249+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true)
250 require.Error(t, err)250 require.Error(t, err)
251 var got dto.AppDeployResponse251 var got dto.AppDeployResponse
252 require.NoError(t, json.Unmarshal(out.Bytes(), &got))252 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
@@ -440,16 +440,16 @@ func TestRunAppsSecretsDelete(t *testing.T) {
440 func TestRunAppRestart_AndLifecycle(t *testing.T) {440 func TestRunAppRestart_AndLifecycle(t *testing.T) {
441 resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}441 resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
442 plane := appPlane(t)442 plane := appPlane(t)
443- plane.EXPECT().RestartApp(mock.Anything, "blog", "").Return(resp, "key-lc", nil).Times(2)443+ plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2)
444 plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()444 plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
445 plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()445 plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
446 plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()446 plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
447 var out bytes.Buffer447 var out bytes.Buffer
448- require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, false))448+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false))
449 assert.Contains(t, out.String(), "success")449 assert.Contains(t, out.String(), "success")
450 450
451 out.Reset()451 out.Reset()
452- require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", &out, true))452+ require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true))
453 var got dto.AppDeployResponse453 var got dto.AppDeployResponse
454 require.NoError(t, json.Unmarshal(out.Bytes(), &got))454 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
455 assert.Equal(t, *resp, got)455 assert.Equal(t, *resp, got)
M internal/adapters/in/cli/apps_watch_test.go +5 -5
@@ -46,7 +46,7 @@ func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
4646 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
4747
4848 var out, errOut bytes.Buffer
49- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
49+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
5050 text := out.String()
5151 assert.Contains(t, text, "op op-1:")
5252 assert.Contains(t, text, "running")
@@ -69,7 +69,7 @@ func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
6969 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
7070
7171 var out, errOut bytes.Buffer
72- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
72+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
7373 assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
7474 "initial and one advanced poll render progress; the unchanged poll is not reprinted")
7575 }
@@ -84,7 +84,7 @@ func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
8484 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
8585
8686 var out, errOut bytes.Buffer
87- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
87+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
8888 }
8989
9090 func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
@@ -99,7 +99,7 @@ func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
9999 }, nil).Once()
100100
101101 var out, errOut bytes.Buffer
102- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)
102+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false)
103103 require.Error(t, err, "a terminal partial outcome must exit nonzero")
104104 assert.Contains(t, err.Error(), "partial")
105105 assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
@@ -116,7 +116,7 @@ func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
116116 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117117
118118 var out, errOut bytes.Buffer
119- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, true))
119+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, true))
120120
121121 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122122 var got dto.AppDeployResponse
@@ -46,7 +46,7 @@ func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
46 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()46 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
47 47
48 var out, errOut bytes.Buffer48 var out, errOut bytes.Buffer
49- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))49+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
50 text := out.String()50 text := out.String()
51 assert.Contains(t, text, "op op-1:")51 assert.Contains(t, text, "op op-1:")
52 assert.Contains(t, text, "running")52 assert.Contains(t, text, "running")
@@ -69,7 +69,7 @@ func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
69 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()69 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
70 70
71 var out, errOut bytes.Buffer71 var out, errOut bytes.Buffer
72- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))72+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
73 assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),73 assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
74 "initial and one advanced poll render progress; the unchanged poll is not reprinted")74 "initial and one advanced poll render progress; the unchanged poll is not reprinted")
75 }75 }
@@ -84,7 +84,7 @@ func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
84 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()84 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
85 85
86 var out, errOut bytes.Buffer86 var out, errOut bytes.Buffer
87- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))87+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
88 }88 }
89 89
90 func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {90 func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
@@ -99,7 +99,7 @@ func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
99 }, nil).Once()99 }, nil).Once()
100 100
101 var out, errOut bytes.Buffer101 var out, errOut bytes.Buffer
102- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)102+ err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false)
103 require.Error(t, err, "a terminal partial outcome must exit nonzero")103 require.Error(t, err, "a terminal partial outcome must exit nonzero")
104 assert.Contains(t, err.Error(), "partial")104 assert.Contains(t, err.Error(), "partial")
105 assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")105 assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
@@ -116,7 +116,7 @@ func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
116 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()116 &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117 117
118 var out, errOut bytes.Buffer118 var out, errOut bytes.Buffer
119- require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, true))119+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, true))
120 120
121 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))121 dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122 var got dto.AppDeployResponse122 var got dto.AppDeployResponse
M internal/adapters/in/cli/controlplane.go +1 -1
@@ -20,7 +20,7 @@ type ControlPlane interface {
2020 DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
2121 StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
2222 StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
23- RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)
23+ RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error)
2424 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
2525 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
2626 ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
@@ -20,7 +20,7 @@ type ControlPlane interface {
20 DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)20 DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error)
21 StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)21 StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
22 StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)22 StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
23- RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)23+ RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error)
24 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)24 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
25 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)25 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
26 ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)26 ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
M internal/adapters/in/cli/controlplane_remote.go +2 -2
@@ -48,8 +48,8 @@ func (r *remoteControlPlane) StartApp(ctx context.Context, app string) (*dto.App
4848 return r.client.StartApp(ctx, app)
4949 }
5050
51-func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {
52- return r.client.RestartApp(ctx, app, service)
51+func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) {
52+ return r.client.RestartApp(ctx, app, service, all)
5353 }
5454
5555 func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
@@ -48,8 +48,8 @@ func (r *remoteControlPlane) StartApp(ctx context.Context, app string) (*dto.App
48 return r.client.StartApp(ctx, app)48 return r.client.StartApp(ctx, app)
49 }49 }
50 50
51-func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {51+func (r *remoteControlPlane) RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) {
52- return r.client.RestartApp(ctx, app, service)52+ return r.client.RestartApp(ctx, app, service, all)
53 }53 }
54 54
55 func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {55 func (r *remoteControlPlane) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
M internal/adapters/in/cli/mocks/mock_control_plane.go +20 -14
@@ -1560,8 +1560,8 @@ func (_c *MockControlPlane_RemoveApp_Call) RunAndReturn(run func(ctx context.Con
15601560 }
15611561
15621562 // RestartApp provides a mock function for the type MockControlPlane
1563-func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error) {
1564- ret := _mock.Called(ctx, app, service)
1563+func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string, all bool) (*dto.AppDeployResponse, string, error) {
1564+ ret := _mock.Called(ctx, app, service, all)
15651565
15661566 if len(ret) == 0 {
15671567 panic("no return value specified for RestartApp")
@@ -1570,23 +1570,23 @@ func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, servi
15701570 var r0 *dto.AppDeployResponse
15711571 var r1 string
15721572 var r2 error
1573- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, string, error)); ok {
1574- return returnFunc(ctx, app, service)
1573+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool) (*dto.AppDeployResponse, string, error)); ok {
1574+ return returnFunc(ctx, app, service, all)
15751575 }
1576- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok {
1577- r0 = returnFunc(ctx, app, service)
1576+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool) *dto.AppDeployResponse); ok {
1577+ r0 = returnFunc(ctx, app, service, all)
15781578 } else {
15791579 if ret.Get(0) != nil {
15801580 r0 = ret.Get(0).(*dto.AppDeployResponse)
15811581 }
15821582 }
1583- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
1584- r1 = returnFunc(ctx, app, service)
1583+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, bool) string); ok {
1584+ r1 = returnFunc(ctx, app, service, all)
15851585 } else {
15861586 r1 = ret.Get(1).(string)
15871587 }
1588- if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
1589- r2 = returnFunc(ctx, app, service)
1588+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, bool) error); ok {
1589+ r2 = returnFunc(ctx, app, service, all)
15901590 } else {
15911591 r2 = ret.Error(2)
15921592 }
@@ -1602,11 +1602,12 @@ type MockControlPlane_RestartApp_Call struct {
16021602 // - ctx context.Context
16031603 // - app string
16041604 // - service string
1605-func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any) *MockControlPlane_RestartApp_Call {
1606- return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service)}
1605+// - all bool
1606+func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any, all any) *MockControlPlane_RestartApp_Call {
1607+ return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service, all)}
16071608 }
16081609
1609-func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string)) *MockControlPlane_RestartApp_Call {
1610+func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string, all bool)) *MockControlPlane_RestartApp_Call {
16101611 _c.Call.Run(func(args mock.Arguments) {
16111612 var arg0 context.Context
16121613 if args[0] != nil {
@@ -1620,10 +1621,15 @@ func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, ap
16201621 if args[2] != nil {
16211622 arg2 = args[2].(string)
16221623 }
1624+ var arg3 bool
1625+ if args[3] != nil {
1626+ arg3 = args[3].(bool)
1627+ }
16231628 run(
16241629 arg0,
16251630 arg1,
16261631 arg2,
1632+ arg3,
16271633 )
16281634 })
16291635 return _c
@@ -1634,7 +1640,7 @@ func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDep
16341640 return _c
16351641 }
16361642
1637-func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call {
1643+func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string, all bool) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call {
16381644 _c.Call.Return(run)
16391645 return _c
16401646 }
@@ -1560,8 +1560,8 @@ func (_c *MockControlPlane_RemoveApp_Call) RunAndReturn(run func(ctx context.Con
1560 }1560 }
1561 1561
1562 // RestartApp provides a mock function for the type MockControlPlane1562 // RestartApp provides a mock function for the type MockControlPlane
1563-func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error) {1563+func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, service string, all bool) (*dto.AppDeployResponse, string, error) {
1564- ret := _mock.Called(ctx, app, service)1564+ ret := _mock.Called(ctx, app, service, all)
1565 1565
1566 if len(ret) == 0 {1566 if len(ret) == 0 {
1567 panic("no return value specified for RestartApp")1567 panic("no return value specified for RestartApp")
@@ -1570,23 +1570,23 @@ func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, servi
1570 var r0 *dto.AppDeployResponse1570 var r0 *dto.AppDeployResponse
1571 var r1 string1571 var r1 string
1572 var r2 error1572 var r2 error
1573- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.AppDeployResponse, string, error)); ok {1573+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool) (*dto.AppDeployResponse, string, error)); ok {
1574- return returnFunc(ctx, app, service)1574+ return returnFunc(ctx, app, service, all)
1575 }1575 }
1576- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.AppDeployResponse); ok {1576+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool) *dto.AppDeployResponse); ok {
1577- r0 = returnFunc(ctx, app, service)1577+ r0 = returnFunc(ctx, app, service, all)
1578 } else {1578 } else {
1579 if ret.Get(0) != nil {1579 if ret.Get(0) != nil {
1580 r0 = ret.Get(0).(*dto.AppDeployResponse)1580 r0 = ret.Get(0).(*dto.AppDeployResponse)
1581 }1581 }
1582 }1582 }
1583- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {1583+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, bool) string); ok {
1584- r1 = returnFunc(ctx, app, service)1584+ r1 = returnFunc(ctx, app, service, all)
1585 } else {1585 } else {
1586 r1 = ret.Get(1).(string)1586 r1 = ret.Get(1).(string)
1587 }1587 }
1588- if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {1588+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, bool) error); ok {
1589- r2 = returnFunc(ctx, app, service)1589+ r2 = returnFunc(ctx, app, service, all)
1590 } else {1590 } else {
1591 r2 = ret.Error(2)1591 r2 = ret.Error(2)
1592 }1592 }
@@ -1602,11 +1602,12 @@ type MockControlPlane_RestartApp_Call struct {
1602 // - ctx context.Context1602 // - ctx context.Context
1603 // - app string1603 // - app string
1604 // - service string1604 // - service string
1605-func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any) *MockControlPlane_RestartApp_Call {1605+// - all bool
1606- return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service)}1606+func (_e *MockControlPlane_Expecter) RestartApp(ctx any, app any, service any, all any) *MockControlPlane_RestartApp_Call {
1607+ return &MockControlPlane_RestartApp_Call{Call: _e.mock.On("RestartApp", ctx, app, service, all)}
1607 }1608 }
1608 1609
1609-func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string)) *MockControlPlane_RestartApp_Call {1610+func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, app string, service string, all bool)) *MockControlPlane_RestartApp_Call {
1610 _c.Call.Run(func(args mock.Arguments) {1611 _c.Call.Run(func(args mock.Arguments) {
1611 var arg0 context.Context1612 var arg0 context.Context
1612 if args[0] != nil {1613 if args[0] != nil {
@@ -1620,10 +1621,15 @@ func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, ap
1620 if args[2] != nil {1621 if args[2] != nil {
1621 arg2 = args[2].(string)1622 arg2 = args[2].(string)
1622 }1623 }
1624+ var arg3 bool
1625+ if args[3] != nil {
1626+ arg3 = args[3].(bool)
1627+ }
1623 run(1628 run(
1624 arg0,1629 arg0,
1625 arg1,1630 arg1,
1626 arg2,1631 arg2,
1632+ arg3,
1627 )1633 )
1628 })1634 })
1629 return _c1635 return _c
@@ -1634,7 +1640,7 @@ func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDep
1634 return _c1640 return _c
1635 }1641 }
1636 1642
1637-func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call {1643+func (_c *MockControlPlane_RestartApp_Call) RunAndReturn(run func(ctx context.Context, app string, service string, all bool) (*dto.AppDeployResponse, string, error)) *MockControlPlane_RestartApp_Call {
1638 _c.Call.Return(run)1644 _c.Call.Return(run)
1639 return _c1645 return _c
1640 }1646 }
M internal/adapters/in/cli/remote/apps.go +10 -2
@@ -166,11 +166,19 @@ func (c *Client) StartApp(ctx context.Context, app string) (*dto.AppDeployRespon
166166 }
167167
168168 // RestartApp restarts from pinned digests (no re-resolution).
169-func (c *Client) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {
169+// all confirms an app-wide restart of a multi-service app.
170+func (c *Client) RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) {
170171 key := newIdempotencyKey()
171172 path := "/apps/" + url.PathEscape(app) + "/restart"
173+ query := url.Values{}
172174 if service != "" {
173- path += "?service=" + url.QueryEscape(service)
175+ query.Set("service", service)
176+ }
177+ if all {
178+ query.Set("all", "true")
179+ }
180+ if len(query) > 0 {
181+ path += "?" + query.Encode()
174182 }
175183 resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil)
176184 if err != nil {
@@ -166,11 +166,19 @@ func (c *Client) StartApp(ctx context.Context, app string) (*dto.AppDeployRespon
166 }166 }
167 167
168 // RestartApp restarts from pinned digests (no re-resolution).168 // RestartApp restarts from pinned digests (no re-resolution).
169-func (c *Client) RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error) {169+// all confirms an app-wide restart of a multi-service app.
170+func (c *Client) RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) {
170 key := newIdempotencyKey()171 key := newIdempotencyKey()
171 path := "/apps/" + url.PathEscape(app) + "/restart"172 path := "/apps/" + url.PathEscape(app) + "/restart"
173+ query := url.Values{}
172 if service != "" {174 if service != "" {
173- path += "?service=" + url.QueryEscape(service)175+ query.Set("service", service)
176+ }
177+ if all {
178+ query.Set("all", "true")
179+ }
180+ if len(query) > 0 {
181+ path += "?" + query.Encode()
174 }182 }
175 resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil)183 resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil)
176 if err != nil {184 if err != nil {
M internal/adapters/in/cli/remote/apps_test.go +20 -0
@@ -105,6 +105,26 @@ func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
105105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106106 }
107107
108+func TestClientRestartApp_SendsServiceScope(t *testing.T) {
109+ var queries []string
110+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111+ queries = append(queries, r.URL.RawQuery)
112+ w.Header().Set("Content-Type", "application/json")
113+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
114+ }))
115+ defer srv.Close()
116+
117+ client := NewClient(srv.URL)
118+ for _, tc := range []struct {
119+ service string
120+ all bool
121+ }{{"", false}, {"web", false}, {"", true}} {
122+ _, _, err := client.RestartApp(context.Background(), "blog", tc.service, tc.all)
123+ require.NoError(t, err)
124+ }
125+ assert.Equal(t, []string{"", "service=web", "all=true"}, queries)
126+}
127+
108128 // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts
109129 // the 202 running response: it decodes the journal and returns the key so the
110130 // caller can poll operations/by-key.
@@ -105,6 +105,26 @@ func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106 }106 }
107 107
108+func TestClientRestartApp_SendsServiceScope(t *testing.T) {
109+ var queries []string
110+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111+ queries = append(queries, r.URL.RawQuery)
112+ w.Header().Set("Content-Type", "application/json")
113+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
114+ }))
115+ defer srv.Close()
116+
117+ client := NewClient(srv.URL)
118+ for _, tc := range []struct {
119+ service string
120+ all bool
121+ }{{"", false}, {"web", false}, {"", true}} {
122+ _, _, err := client.RestartApp(context.Background(), "blog", tc.service, tc.all)
123+ require.NoError(t, err)
124+ }
125+ assert.Equal(t, []string{"", "service=web", "all=true"}, queries)
126+}
127+
108 // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts128 // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts
109 // the 202 running response: it decodes the journal and returns the key so the129 // the 202 running response: it decodes the journal and returns the key so the
110 // caller can poll operations/by-key.130 // caller can poll operations/by-key.
M internal/adapters/in/http/admin/handler_apps.go +6 -3
@@ -303,7 +303,7 @@ func (h *Handler) handleAppDeploy(w http.ResponseWriter, r *http.Request, app st
303303 if !ok {
304304 return
305305 }
306- op, err := svc.Deploy(ctx, app, req.Revision, req.Service, key)
306+ op, err := svc.Deploy(ctx, app, req.Revision, req.Service, req.All, key)
307307 if err != nil && (op == nil || isMappedPreflightError(err, op)) {
308308 h.sendAppOpError(w, err)
309309 return
@@ -376,8 +376,9 @@ func (h *Handler) handleAppRestart(w http.ResponseWriter, r *http.Request, app s
376376 if !ok {
377377 return
378378 }
379- service := r.URL.Query().Get("service")
380- op, err := svc.Restart(ctx, app, service, key)
379+ query := r.URL.Query()
380+ all := query.Get("all") == "true"
381+ op, err := svc.Restart(ctx, app, query.Get("service"), all, key)
381382 if err != nil && op == nil {
382383 h.sendAppOpError(w, err)
383384 return
@@ -557,6 +558,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
557558 h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy")
558559 case errors.Is(err, domain.ErrAppUnmanagedImageVolume):
559560 h.sendAppError(w, http.StatusBadRequest, "unmanaged-image-volume", err.Error(), "", "")
561+ case errors.Is(err, domain.ErrAppServiceScope):
562+ h.sendAppError(w, http.StatusBadRequest, "service-scope-required", err.Error(), "", "pass --service NAME or --all")
560563 case errors.Is(err, domain.ErrAppNotFound):
561564 h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name")
562565 case errors.Is(err, domain.ErrAppRevisionNotFound),
@@ -303,7 +303,7 @@ func (h *Handler) handleAppDeploy(w http.ResponseWriter, r *http.Request, app st
303 if !ok {303 if !ok {
304 return304 return
305 }305 }
306- op, err := svc.Deploy(ctx, app, req.Revision, req.Service, key)306+ op, err := svc.Deploy(ctx, app, req.Revision, req.Service, req.All, key)
307 if err != nil && (op == nil || isMappedPreflightError(err, op)) {307 if err != nil && (op == nil || isMappedPreflightError(err, op)) {
308 h.sendAppOpError(w, err)308 h.sendAppOpError(w, err)
309 return309 return
@@ -376,8 +376,9 @@ func (h *Handler) handleAppRestart(w http.ResponseWriter, r *http.Request, app s
376 if !ok {376 if !ok {
377 return377 return
378 }378 }
379- service := r.URL.Query().Get("service")379+ query := r.URL.Query()
380- op, err := svc.Restart(ctx, app, service, key)380+ all := query.Get("all") == "true"
381+ op, err := svc.Restart(ctx, app, query.Get("service"), all, key)
381 if err != nil && op == nil {382 if err != nil && op == nil {
382 h.sendAppOpError(w, err)383 h.sendAppOpError(w, err)
383 return384 return
@@ -557,6 +558,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
557 h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy")558 h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy")
558 case errors.Is(err, domain.ErrAppUnmanagedImageVolume):559 case errors.Is(err, domain.ErrAppUnmanagedImageVolume):
559 h.sendAppError(w, http.StatusBadRequest, "unmanaged-image-volume", err.Error(), "", "")560 h.sendAppError(w, http.StatusBadRequest, "unmanaged-image-volume", err.Error(), "", "")
561+ case errors.Is(err, domain.ErrAppServiceScope):
562+ h.sendAppError(w, http.StatusBadRequest, "service-scope-required", err.Error(), "", "pass --service NAME or --all")
560 case errors.Is(err, domain.ErrAppNotFound):563 case errors.Is(err, domain.ErrAppNotFound):
561 h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name")564 h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name")
562 case errors.Is(err, domain.ErrAppRevisionNotFound),565 case errors.Is(err, domain.ErrAppRevisionNotFound),
M internal/adapters/in/http/admin/handler_apps_test.go +46 -7
@@ -131,7 +131,7 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
131131 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
132132 },
133133 }
134- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(op, nil).Once()
134+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
135135 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
136136 App: "blog", Converged: true, ConvergedRevision: "rev-1",
137137 Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},
@@ -171,7 +171,7 @@ func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
171171 Service: "web", Leftover: "ctr-old", Detail: "remove: still present",
172172 }},
173173 }
174- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(op, nil).Once()
174+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
175175 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()
176176
177177 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
@@ -201,7 +201,7 @@ func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
201201 {ID: "service.web.replace", State: domain.AppStepPending},
202202 },
203203 }
204- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, nil).Once()
204+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, nil).Once()
205205 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
206206
207207 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -229,7 +229,7 @@ func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
229229 Outcome: domain.AppOutcomeSuccess,
230230 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
231231 }
232- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(terminal, nil).Once()
232+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(terminal, nil).Once()
233233 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
234234
235235 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -258,7 +258,7 @@ func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
258258 }},
259259 }
260260 replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
261- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(failed, replayErr).Once()
261+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(failed, replayErr).Once()
262262 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
263263
264264 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -326,7 +326,7 @@ func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
326326 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
327327 }
328328 replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
329- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()
329+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
330330 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
331331
332332 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -351,7 +351,7 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
351351 },
352352 }
353353 replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
354- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()
354+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
355355 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
356356
357357 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -360,6 +360,45 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
360360 assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
361361 }
362362
363+func TestHandler_AppDeploy_ServiceScopeRequiredIs400(t *testing.T) {
364+ appSvc := inmocks.NewMockAppService(t)
365+ handler := appsTestHandler(t, appSvc)
366+ scopeErr := fmt.Errorf("app blog has several services (db, web): pass --service NAME or --all: %w", domain.ErrAppServiceScope)
367+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(nil, scopeErr).Once()
368+
369+ srv := newScopedTestServer(t, handler, "admin:apps:write")
370+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
371+ require.Equal(t, http.StatusBadRequest, status)
372+
373+ var resp dto.AppError
374+ require.NoError(t, json.Unmarshal(body, &resp))
375+ assert.Equal(t, "service-scope-required", resp.Error)
376+ assert.Contains(t, resp.Message, "(db, web)")
377+}
378+
379+func TestHandler_AppDeploy_PassesAll(t *testing.T) {
380+ appSvc := inmocks.NewMockAppService(t)
381+ handler := appsTestHandler(t, appSvc)
382+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
383+
384+ srv := newScopedTestServer(t, handler, "admin:apps:write")
385+ status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{All: true})
386+ require.Equal(t, http.StatusConflict, status)
387+}
388+
389+func TestHandler_AppRestart_PassesServiceAndAll(t *testing.T) {
390+ appSvc := inmocks.NewMockAppService(t)
391+ handler := appsTestHandler(t, appSvc)
392+ appSvc.EXPECT().Restart(mock.Anything, "blog", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
393+ appSvc.EXPECT().Restart(mock.Anything, "blog", "web", false, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
394+
395+ srv := newScopedTestServer(t, handler, "admin:apps:write")
396+ status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?all=true", nil)
397+ require.Equal(t, http.StatusConflict, status)
398+ status, _ = appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?service=web", nil)
399+ require.Equal(t, http.StatusConflict, status)
400+}
401+
363402 // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
364403 // validation contract: a deploy without a valid key never reaches the
365404 // service.
@@ -131,7 +131,7 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
131 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},131 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
132 },132 },
133 }133 }
134- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(op, nil).Once()134+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
135 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{135 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
136 App: "blog", Converged: true, ConvergedRevision: "rev-1",136 App: "blog", Converged: true, ConvergedRevision: "rev-1",
137 Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},137 Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},
@@ -171,7 +171,7 @@ func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
171 Service: "web", Leftover: "ctr-old", Detail: "remove: still present",171 Service: "web", Leftover: "ctr-old", Detail: "remove: still present",
172 }},172 }},
173 }173 }
174- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(op, nil).Once()174+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
175 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()175 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()
176 176
177 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",177 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
@@ -201,7 +201,7 @@ func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
201 {ID: "service.web.replace", State: domain.AppStepPending},201 {ID: "service.web.replace", State: domain.AppStepPending},
202 },202 },
203 }203 }
204- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, nil).Once()204+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, nil).Once()
205 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()205 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
206 206
207 srv := newScopedTestServer(t, handler, "admin:apps:write")207 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -229,7 +229,7 @@ func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
229 Outcome: domain.AppOutcomeSuccess,229 Outcome: domain.AppOutcomeSuccess,
230 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},230 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
231 }231 }
232- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(terminal, nil).Once()232+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(terminal, nil).Once()
233 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()233 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
234 234
235 srv := newScopedTestServer(t, handler, "admin:apps:write")235 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -258,7 +258,7 @@ func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
258 }},258 }},
259 }259 }
260 replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)260 replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
261- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(failed, replayErr).Once()261+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(failed, replayErr).Once()
262 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()262 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
263 263
264 srv := newScopedTestServer(t, handler, "admin:apps:write")264 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -326,7 +326,7 @@ func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
326 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},326 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
327 }327 }
328 replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)328 replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
329- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()329+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
330 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()330 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
331 331
332 srv := newScopedTestServer(t, handler, "admin:apps:write")332 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -351,7 +351,7 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
351 },351 },
352 }352 }
353 replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)353 replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
354- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()354+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
355 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()355 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
356 356
357 srv := newScopedTestServer(t, handler, "admin:apps:write")357 srv := newScopedTestServer(t, handler, "admin:apps:write")
@@ -360,6 +360,45 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
360 assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")360 assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
361 }361 }
362 362
363+func TestHandler_AppDeploy_ServiceScopeRequiredIs400(t *testing.T) {
364+ appSvc := inmocks.NewMockAppService(t)
365+ handler := appsTestHandler(t, appSvc)
366+ scopeErr := fmt.Errorf("app blog has several services (db, web): pass --service NAME or --all: %w", domain.ErrAppServiceScope)
367+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(nil, scopeErr).Once()
368+
369+ srv := newScopedTestServer(t, handler, "admin:apps:write")
370+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
371+ require.Equal(t, http.StatusBadRequest, status)
372+
373+ var resp dto.AppError
374+ require.NoError(t, json.Unmarshal(body, &resp))
375+ assert.Equal(t, "service-scope-required", resp.Error)
376+ assert.Contains(t, resp.Message, "(db, web)")
377+}
378+
379+func TestHandler_AppDeploy_PassesAll(t *testing.T) {
380+ appSvc := inmocks.NewMockAppService(t)
381+ handler := appsTestHandler(t, appSvc)
382+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
383+
384+ srv := newScopedTestServer(t, handler, "admin:apps:write")
385+ status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{All: true})
386+ require.Equal(t, http.StatusConflict, status)
387+}
388+
389+func TestHandler_AppRestart_PassesServiceAndAll(t *testing.T) {
390+ appSvc := inmocks.NewMockAppService(t)
391+ handler := appsTestHandler(t, appSvc)
392+ appSvc.EXPECT().Restart(mock.Anything, "blog", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
393+ appSvc.EXPECT().Restart(mock.Anything, "blog", "web", false, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
394+
395+ srv := newScopedTestServer(t, handler, "admin:apps:write")
396+ status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?all=true", nil)
397+ require.Equal(t, http.StatusConflict, status)
398+ status, _ = appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?service=web", nil)
399+ require.Equal(t, http.StatusConflict, status)
400+}
401+
363 // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request402 // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
364 // validation contract: a deploy without a valid key never reaches the403 // validation contract: a deploy without a valid key never reaches the
365 // service.404 // service.
M internal/app/app_deploy_handler_lifecycle_test.go +3 -0
@@ -77,6 +77,9 @@ func TestAppDeployHandler_RequestCancellationDoesNotAbortExecution(t *testing.T)
7777 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once()
7878 // Show resolves no app: the response carries the journal alone.
7979 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
80+ // The app-wide service scope check sees no services and lets it through.
81+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
82+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
8083
8184 release := make(chan struct{})
8285 var releaseOnce sync.Once
@@ -77,6 +77,9 @@ func TestAppDeployHandler_RequestCancellationDoesNotAbortExecution(t *testing.T)
77 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once()77 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once()
78 // Show resolves no app: the response carries the journal alone.78 // Show resolves no app: the response carries the journal alone.
79 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()79 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
80+ // The app-wide service scope check sees no services and lets it through.
81+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
82+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
80 83
81 release := make(chan struct{})84 release := make(chan struct{})
82 var releaseOnce sync.Once85 var releaseOnce sync.Once
M internal/app/run_app_lifecycle_test.go +1 -1
@@ -85,7 +85,7 @@ func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing.
8585 deployDone := make(chan struct{})
8686 go func() {
8787 defer close(deployDone)
88- _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", "key-1")
88+ _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", false, "key-1")
8989 }()
9090
9191 var execCtx context.Context
@@ -85,7 +85,7 @@ func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing.
85 deployDone := make(chan struct{})85 deployDone := make(chan struct{})
86 go func() {86 go func() {
87 defer close(deployDone)87 defer close(deployDone)
88- _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", "key-1")88+ _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", false, "key-1")
89 }()89 }()
90 90
91 var execCtx context.Context91 var execCtx context.Context
M internal/boundaries/in/apps.go +6 -4
@@ -35,8 +35,9 @@ type AppService interface {
3535 Diff(ctx context.Context, app string) (domain.AppDiff, error)
3636
3737 // Deploy activates a captured revision (empty revision means the
38- // desired head; empty service means all services).
39- Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error)
38+ // desired head). An empty service targets every service, which a
39+ // multi-service app accepts only with all set (domain.ErrAppServiceScope).
40+ Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)
4041
4142 // Stop persists the durable stopped intent and stops exact containers.
4243 Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)
@@ -45,8 +46,9 @@ type AppService interface {
4546 Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)
4647
4748 // Restart restarts from pinned digests without re-resolution.
48- // Empty service means all services.
49- Restart(ctx context.Context, app, service, idempotencyKey string) (*domain.AppOperation, error)
49+ // An empty service targets every service, which a multi-service app
50+ // accepts only with all set (domain.ErrAppServiceScope).
51+ Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)
5052
5153 // Remove withdraws workloads; volumes and secrets are retained as
5254 // owned orphans and the name stays reserved.
@@ -35,8 +35,9 @@ type AppService interface {
35 Diff(ctx context.Context, app string) (domain.AppDiff, error)35 Diff(ctx context.Context, app string) (domain.AppDiff, error)
36 36
37 // Deploy activates a captured revision (empty revision means the37 // Deploy activates a captured revision (empty revision means the
38- // desired head; empty service means all services).38+ // desired head). An empty service targets every service, which a
39- Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error)39+ // multi-service app accepts only with all set (domain.ErrAppServiceScope).
40+ Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)
40 41
41 // Stop persists the durable stopped intent and stops exact containers.42 // Stop persists the durable stopped intent and stops exact containers.
42 Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)43 Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)
@@ -45,8 +46,9 @@ type AppService interface {
45 Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)46 Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error)
46 47
47 // Restart restarts from pinned digests without re-resolution.48 // Restart restarts from pinned digests without re-resolution.
48- // Empty service means all services.49+ // An empty service targets every service, which a multi-service app
49- Restart(ctx context.Context, app, service, idempotencyKey string) (*domain.AppOperation, error)50+ // accepts only with all set (domain.ErrAppServiceScope).
51+ Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)
50 52
51 // Remove withdraws workloads; volumes and secrets are retained as53 // Remove withdraws workloads; volumes and secrets are retained as
52 // owned orphans and the name stays reserved.54 // owned orphans and the name stays reserved.
M internal/boundaries/in/mocks/mock_app_service.go +40 -28
@@ -197,8 +197,8 @@ func (_c *MockAppService_DeleteSecret_Call) RunAndReturn(run func(ctx context.Co
197197 }
198198
199199 // Deploy provides a mock function for the type MockAppService
200-func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision string, service string, idempotencyKey string) (*domain.AppOperation, error) {
201- ret := _mock.Called(ctx, app, revision, service, idempotencyKey)
200+func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
201+ ret := _mock.Called(ctx, app, revision, service, all, idempotencyKey)
202202
203203 if len(ret) == 0 {
204204 panic("no return value specified for Deploy")
@@ -206,18 +206,18 @@ func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision st
206206
207207 var r0 *domain.AppOperation
208208 var r1 error
209- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*domain.AppOperation, error)); ok {
210- return returnFunc(ctx, app, revision, service, idempotencyKey)
209+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string) (*domain.AppOperation, error)); ok {
210+ return returnFunc(ctx, app, revision, service, all, idempotencyKey)
211211 }
212- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *domain.AppOperation); ok {
213- r0 = returnFunc(ctx, app, revision, service, idempotencyKey)
212+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string) *domain.AppOperation); ok {
213+ r0 = returnFunc(ctx, app, revision, service, all, idempotencyKey)
214214 } else {
215215 if ret.Get(0) != nil {
216216 r0 = ret.Get(0).(*domain.AppOperation)
217217 }
218218 }
219- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {
220- r1 = returnFunc(ctx, app, revision, service, idempotencyKey)
219+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string) error); ok {
220+ r1 = returnFunc(ctx, app, revision, service, all, idempotencyKey)
221221 } else {
222222 r1 = ret.Error(1)
223223 }
@@ -234,12 +234,13 @@ type MockAppService_Deploy_Call struct {
234234 // - app string
235235 // - revision string
236236 // - service string
237+// - all bool
237238 // - idempotencyKey string
238-func (_e *MockAppService_Expecter) Deploy(ctx any, app any, revision any, service any, idempotencyKey any) *MockAppService_Deploy_Call {
239- return &MockAppService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, app, revision, service, idempotencyKey)}
239+func (_e *MockAppService_Expecter) Deploy(ctx any, app any, revision any, service any, all any, idempotencyKey any) *MockAppService_Deploy_Call {
240+ return &MockAppService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, app, revision, service, all, idempotencyKey)}
240241 }
241242
242-func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app string, revision string, service string, idempotencyKey string)) *MockAppService_Deploy_Call {
243+func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string)) *MockAppService_Deploy_Call {
243244 _c.Call.Run(func(args mock.Arguments) {
244245 var arg0 context.Context
245246 if args[0] != nil {
@@ -257,9 +258,13 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri
257258 if args[3] != nil {
258259 arg3 = args[3].(string)
259260 }
260- var arg4 string
261+ var arg4 bool
261262 if args[4] != nil {
262- arg4 = args[4].(string)
263+ arg4 = args[4].(bool)
264+ }
265+ var arg5 string
266+ if args[5] != nil {
267+ arg5 = args[5].(string)
263268 }
264269 run(
265270 arg0,
@@ -267,6 +272,7 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri
267272 arg2,
268273 arg3,
269274 arg4,
275+ arg5,
270276 )
271277 })
272278 return _c
@@ -277,7 +283,7 @@ func (_c *MockAppService_Deploy_Call) Return(appOperation *domain.AppOperation,
277283 return _c
278284 }
279285
280-func (_c *MockAppService_Deploy_Call) RunAndReturn(run func(ctx context.Context, app string, revision string, service string, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Deploy_Call {
286+func (_c *MockAppService_Deploy_Call) RunAndReturn(run func(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Deploy_Call {
281287 _c.Call.Return(run)
282288 return _c
283289 }
@@ -633,8 +639,8 @@ func (_c *MockAppService_Remove_Call) RunAndReturn(run func(ctx context.Context,
633639 }
634640
635641 // Restart provides a mock function for the type MockAppService
636-func (_mock *MockAppService) Restart(ctx context.Context, app string, service string, idempotencyKey string) (*domain.AppOperation, error) {
637- ret := _mock.Called(ctx, app, service, idempotencyKey)
642+func (_mock *MockAppService) Restart(ctx context.Context, app string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
643+ ret := _mock.Called(ctx, app, service, all, idempotencyKey)
638644
639645 if len(ret) == 0 {
640646 panic("no return value specified for Restart")
@@ -642,18 +648,18 @@ func (_mock *MockAppService) Restart(ctx context.Context, app string, service st
642648
643649 var r0 *domain.AppOperation
644650 var r1 error
645- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*domain.AppOperation, error)); ok {
646- return returnFunc(ctx, app, service, idempotencyKey)
651+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool, string) (*domain.AppOperation, error)); ok {
652+ return returnFunc(ctx, app, service, all, idempotencyKey)
647653 }
648- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *domain.AppOperation); ok {
649- r0 = returnFunc(ctx, app, service, idempotencyKey)
654+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool, string) *domain.AppOperation); ok {
655+ r0 = returnFunc(ctx, app, service, all, idempotencyKey)
650656 } else {
651657 if ret.Get(0) != nil {
652658 r0 = ret.Get(0).(*domain.AppOperation)
653659 }
654660 }
655- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
656- r1 = returnFunc(ctx, app, service, idempotencyKey)
661+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, bool, string) error); ok {
662+ r1 = returnFunc(ctx, app, service, all, idempotencyKey)
657663 } else {
658664 r1 = ret.Error(1)
659665 }
@@ -669,12 +675,13 @@ type MockAppService_Restart_Call struct {
669675 // - ctx context.Context
670676 // - app string
671677 // - service string
678+// - all bool
672679 // - idempotencyKey string
673-func (_e *MockAppService_Expecter) Restart(ctx any, app any, service any, idempotencyKey any) *MockAppService_Restart_Call {
674- return &MockAppService_Restart_Call{Call: _e.mock.On("Restart", ctx, app, service, idempotencyKey)}
680+func (_e *MockAppService_Expecter) Restart(ctx any, app any, service any, all any, idempotencyKey any) *MockAppService_Restart_Call {
681+ return &MockAppService_Restart_Call{Call: _e.mock.On("Restart", ctx, app, service, all, idempotencyKey)}
675682 }
676683
677-func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app string, service string, idempotencyKey string)) *MockAppService_Restart_Call {
684+func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app string, service string, all bool, idempotencyKey string)) *MockAppService_Restart_Call {
678685 _c.Call.Run(func(args mock.Arguments) {
679686 var arg0 context.Context
680687 if args[0] != nil {
@@ -688,15 +695,20 @@ func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app str
688695 if args[2] != nil {
689696 arg2 = args[2].(string)
690697 }
691- var arg3 string
698+ var arg3 bool
692699 if args[3] != nil {
693- arg3 = args[3].(string)
700+ arg3 = args[3].(bool)
701+ }
702+ var arg4 string
703+ if args[4] != nil {
704+ arg4 = args[4].(string)
694705 }
695706 run(
696707 arg0,
697708 arg1,
698709 arg2,
699710 arg3,
711+ arg4,
700712 )
701713 })
702714 return _c
@@ -707,7 +719,7 @@ func (_c *MockAppService_Restart_Call) Return(appOperation *domain.AppOperation,
707719 return _c
708720 }
709721
710-func (_c *MockAppService_Restart_Call) RunAndReturn(run func(ctx context.Context, app string, service string, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Restart_Call {
722+func (_c *MockAppService_Restart_Call) RunAndReturn(run func(ctx context.Context, app string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Restart_Call {
711723 _c.Call.Return(run)
712724 return _c
713725 }
@@ -197,8 +197,8 @@ func (_c *MockAppService_DeleteSecret_Call) RunAndReturn(run func(ctx context.Co
197 }197 }
198 198
199 // Deploy provides a mock function for the type MockAppService199 // Deploy provides a mock function for the type MockAppService
200-func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision string, service string, idempotencyKey string) (*domain.AppOperation, error) {200+func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
201- ret := _mock.Called(ctx, app, revision, service, idempotencyKey)201+ ret := _mock.Called(ctx, app, revision, service, all, idempotencyKey)
202 202
203 if len(ret) == 0 {203 if len(ret) == 0 {
204 panic("no return value specified for Deploy")204 panic("no return value specified for Deploy")
@@ -206,18 +206,18 @@ func (_mock *MockAppService) Deploy(ctx context.Context, app string, revision st
206 206
207 var r0 *domain.AppOperation207 var r0 *domain.AppOperation
208 var r1 error208 var r1 error
209- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*domain.AppOperation, error)); ok {209+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string) (*domain.AppOperation, error)); ok {
210- return returnFunc(ctx, app, revision, service, idempotencyKey)210+ return returnFunc(ctx, app, revision, service, all, idempotencyKey)
211 }211 }
212- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *domain.AppOperation); ok {212+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string) *domain.AppOperation); ok {
213- r0 = returnFunc(ctx, app, revision, service, idempotencyKey)213+ r0 = returnFunc(ctx, app, revision, service, all, idempotencyKey)
214 } else {214 } else {
215 if ret.Get(0) != nil {215 if ret.Get(0) != nil {
216 r0 = ret.Get(0).(*domain.AppOperation)216 r0 = ret.Get(0).(*domain.AppOperation)
217 }217 }
218 }218 }
219- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {219+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string) error); ok {
220- r1 = returnFunc(ctx, app, revision, service, idempotencyKey)220+ r1 = returnFunc(ctx, app, revision, service, all, idempotencyKey)
221 } else {221 } else {
222 r1 = ret.Error(1)222 r1 = ret.Error(1)
223 }223 }
@@ -234,12 +234,13 @@ type MockAppService_Deploy_Call struct {
234 // - app string234 // - app string
235 // - revision string235 // - revision string
236 // - service string236 // - service string
237+// - all bool
237 // - idempotencyKey string238 // - idempotencyKey string
238-func (_e *MockAppService_Expecter) Deploy(ctx any, app any, revision any, service any, idempotencyKey any) *MockAppService_Deploy_Call {239+func (_e *MockAppService_Expecter) Deploy(ctx any, app any, revision any, service any, all any, idempotencyKey any) *MockAppService_Deploy_Call {
239- return &MockAppService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, app, revision, service, idempotencyKey)}240+ return &MockAppService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, app, revision, service, all, idempotencyKey)}
240 }241 }
241 242
242-func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app string, revision string, service string, idempotencyKey string)) *MockAppService_Deploy_Call {243+func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string)) *MockAppService_Deploy_Call {
243 _c.Call.Run(func(args mock.Arguments) {244 _c.Call.Run(func(args mock.Arguments) {
244 var arg0 context.Context245 var arg0 context.Context
245 if args[0] != nil {246 if args[0] != nil {
@@ -257,9 +258,13 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri
257 if args[3] != nil {258 if args[3] != nil {
258 arg3 = args[3].(string)259 arg3 = args[3].(string)
259 }260 }
260- var arg4 string261+ var arg4 bool
261 if args[4] != nil {262 if args[4] != nil {
262- arg4 = args[4].(string)263+ arg4 = args[4].(bool)
264+ }
265+ var arg5 string
266+ if args[5] != nil {
267+ arg5 = args[5].(string)
263 }268 }
264 run(269 run(
265 arg0,270 arg0,
@@ -267,6 +272,7 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri
267 arg2,272 arg2,
268 arg3,273 arg3,
269 arg4,274 arg4,
275+ arg5,
270 )276 )
271 })277 })
272 return _c278 return _c
@@ -277,7 +283,7 @@ func (_c *MockAppService_Deploy_Call) Return(appOperation *domain.AppOperation,
277 return _c283 return _c
278 }284 }
279 285
280-func (_c *MockAppService_Deploy_Call) RunAndReturn(run func(ctx context.Context, app string, revision string, service string, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Deploy_Call {286+func (_c *MockAppService_Deploy_Call) RunAndReturn(run func(ctx context.Context, app string, revision string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Deploy_Call {
281 _c.Call.Return(run)287 _c.Call.Return(run)
282 return _c288 return _c
283 }289 }
@@ -633,8 +639,8 @@ func (_c *MockAppService_Remove_Call) RunAndReturn(run func(ctx context.Context,
633 }639 }
634 640
635 // Restart provides a mock function for the type MockAppService641 // Restart provides a mock function for the type MockAppService
636-func (_mock *MockAppService) Restart(ctx context.Context, app string, service string, idempotencyKey string) (*domain.AppOperation, error) {642+func (_mock *MockAppService) Restart(ctx context.Context, app string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
637- ret := _mock.Called(ctx, app, service, idempotencyKey)643+ ret := _mock.Called(ctx, app, service, all, idempotencyKey)
638 644
639 if len(ret) == 0 {645 if len(ret) == 0 {
640 panic("no return value specified for Restart")646 panic("no return value specified for Restart")
@@ -642,18 +648,18 @@ func (_mock *MockAppService) Restart(ctx context.Context, app string, service st
642 648
643 var r0 *domain.AppOperation649 var r0 *domain.AppOperation
644 var r1 error650 var r1 error
645- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*domain.AppOperation, error)); ok {651+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool, string) (*domain.AppOperation, error)); ok {
646- return returnFunc(ctx, app, service, idempotencyKey)652+ return returnFunc(ctx, app, service, all, idempotencyKey)
647 }653 }
648- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *domain.AppOperation); ok {654+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, bool, string) *domain.AppOperation); ok {
649- r0 = returnFunc(ctx, app, service, idempotencyKey)655+ r0 = returnFunc(ctx, app, service, all, idempotencyKey)
650 } else {656 } else {
651 if ret.Get(0) != nil {657 if ret.Get(0) != nil {
652 r0 = ret.Get(0).(*domain.AppOperation)658 r0 = ret.Get(0).(*domain.AppOperation)
653 }659 }
654 }660 }
655- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {661+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, bool, string) error); ok {
656- r1 = returnFunc(ctx, app, service, idempotencyKey)662+ r1 = returnFunc(ctx, app, service, all, idempotencyKey)
657 } else {663 } else {
658 r1 = ret.Error(1)664 r1 = ret.Error(1)
659 }665 }
@@ -669,12 +675,13 @@ type MockAppService_Restart_Call struct {
669 // - ctx context.Context675 // - ctx context.Context
670 // - app string676 // - app string
671 // - service string677 // - service string
678+// - all bool
672 // - idempotencyKey string679 // - idempotencyKey string
673-func (_e *MockAppService_Expecter) Restart(ctx any, app any, service any, idempotencyKey any) *MockAppService_Restart_Call {680+func (_e *MockAppService_Expecter) Restart(ctx any, app any, service any, all any, idempotencyKey any) *MockAppService_Restart_Call {
674- return &MockAppService_Restart_Call{Call: _e.mock.On("Restart", ctx, app, service, idempotencyKey)}681+ return &MockAppService_Restart_Call{Call: _e.mock.On("Restart", ctx, app, service, all, idempotencyKey)}
675 }682 }
676 683
677-func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app string, service string, idempotencyKey string)) *MockAppService_Restart_Call {684+func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app string, service string, all bool, idempotencyKey string)) *MockAppService_Restart_Call {
678 _c.Call.Run(func(args mock.Arguments) {685 _c.Call.Run(func(args mock.Arguments) {
679 var arg0 context.Context686 var arg0 context.Context
680 if args[0] != nil {687 if args[0] != nil {
@@ -688,15 +695,20 @@ func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app str
688 if args[2] != nil {695 if args[2] != nil {
689 arg2 = args[2].(string)696 arg2 = args[2].(string)
690 }697 }
691- var arg3 string698+ var arg3 bool
692 if args[3] != nil {699 if args[3] != nil {
693- arg3 = args[3].(string)700+ arg3 = args[3].(bool)
701+ }
702+ var arg4 string
703+ if args[4] != nil {
704+ arg4 = args[4].(string)
694 }705 }
695 run(706 run(
696 arg0,707 arg0,
697 arg1,708 arg1,
698 arg2,709 arg2,
699 arg3,710 arg3,
711+ arg4,
700 )712 )
701 })713 })
702 return _c714 return _c
@@ -707,7 +719,7 @@ func (_c *MockAppService_Restart_Call) Return(appOperation *domain.AppOperation,
707 return _c719 return _c
708 }720 }
709 721
710-func (_c *MockAppService_Restart_Call) RunAndReturn(run func(ctx context.Context, app string, service string, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Restart_Call {722+func (_c *MockAppService_Restart_Call) RunAndReturn(run func(ctx context.Context, app string, service string, all bool, idempotencyKey string) (*domain.AppOperation, error)) *MockAppService_Restart_Call {
711 _c.Call.Return(run)723 _c.Call.Return(run)
712 return _c724 return _c
713 }725 }
M internal/domain/errors.go +3 -0
@@ -102,6 +102,9 @@ var (
102102 ErrAppImageNotAllowed = errors.New("image reference not allowed by installation policy")
103103 ErrAppSecretMissing = errors.New("required app secret missing")
104104 ErrAppUnmanagedImageVolume = errors.New("image declares unmanaged volume")
105+ // ErrAppServiceScope marks an app-wide mutation of a multi-service app
106+ // that named neither one service nor all of them.
107+ ErrAppServiceScope = errors.New("app service scope required")
105108 // ErrPruneDisabled means prune could not establish a safe scope for
106109 // the requested operation at all (for example, its protection or
107110 // runtime ports are not wired). It is never returned merely because
@@ -102,6 +102,9 @@ var (
102 ErrAppImageNotAllowed = errors.New("image reference not allowed by installation policy")102 ErrAppImageNotAllowed = errors.New("image reference not allowed by installation policy")
103 ErrAppSecretMissing = errors.New("required app secret missing")103 ErrAppSecretMissing = errors.New("required app secret missing")
104 ErrAppUnmanagedImageVolume = errors.New("image declares unmanaged volume")104 ErrAppUnmanagedImageVolume = errors.New("image declares unmanaged volume")
105+ // ErrAppServiceScope marks an app-wide mutation of a multi-service app
106+ // that named neither one service nor all of them.
107+ ErrAppServiceScope = errors.New("app service scope required")
105 // ErrPruneDisabled means prune could not establish a safe scope for108 // ErrPruneDisabled means prune could not establish a safe scope for
106 // the requested operation at all (for example, its protection or109 // the requested operation at all (for example, its protection or
107 // runtime ports are not wired). It is never returned merely because110 // runtime ports are not wired). It is never returned merely because
M internal/usecase/apps/app_service.go +82 -2
@@ -2,8 +2,10 @@ package apps
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "sort"
8+ "strings"
79 "sync"
810
911 "github.com/bnema/zerowrap"
@@ -405,7 +407,10 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff,
405407 // cancellation cannot abort an in-flight replacement. A repeated idempotency
406408 // key is never owned twice: its stored journal replays instead of spawning a
407409 // second execution, and only the owner schedules any work.
408-func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) {
410+func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
411+ if err := s.requireServiceScope(ctx, app, revision, service, all, true); err != nil {
412+ return nil, err
413+ }
409414 done, err := s.beginDeploy()
410415 if err != nil {
411416 return nil, err
@@ -427,6 +432,78 @@ func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, ide
427432 return s.operationResult(ctx, app, started.Claim.Op, nil)
428433 }
429434
435+// requireServiceScope refuses an app-wide deploy or restart of a
436+// multi-service app unless the caller asked for it with all, so an
437+// unrelated service (a database, say) is never replaced by accident. It
438+// runs before any claim or idempotency key is recorded. Deploy counts the
439+// selected revision — an explicit revision when one is given, the desired
440+// head otherwise — plus ACTIVE, so an older multi-service revision cannot
441+// slip through a single-service head, and a first deploy is covered.
442+// Restart counts ACTIVE only.
443+func (s *AppServiceImpl) requireServiceScope(ctx context.Context, app, revision, service string, all, includeDesired bool) error {
444+ if all && service != "" {
445+ return fmt.Errorf("apps: --all and --service are mutually exclusive: %w", domain.ErrAppServiceScope)
446+ }
447+ if all || service != "" {
448+ return nil
449+ }
450+ names := map[string]struct{}{}
451+ if includeDesired {
452+ desired, err := s.loadScopeRevision(ctx, app, revision)
453+ if err != nil {
454+ return err
455+ }
456+ for _, svc := range desired.Spec.Services {
457+ names[svc.Name] = struct{}{}
458+ }
459+ }
460+ active, ok, err := s.store.LoadActive(ctx, app)
461+ if err != nil {
462+ return fmt.Errorf("apps: load active of app %q for service scope: %w", app, err)
463+ }
464+ if ok {
465+ for name := range active.Services {
466+ names[name] = struct{}{}
467+ }
468+ }
469+ if len(names) <= 1 {
470+ return nil
471+ }
472+ sorted := make([]string, 0, len(names))
473+ for name := range names {
474+ sorted = append(sorted, name)
475+ }
476+ sort.Strings(sorted)
477+ return fmt.Errorf("app %s has several services (%s): pass --service NAME or --all: %w",
478+ app, strings.Join(sorted, ", "), domain.ErrAppServiceScope)
479+}
480+
481+// loadScopeRevision returns the revision a deploy would activate: the
482+// explicit revision when one is selected, the desired head otherwise. A
483+// missing revision counts no services: an unknown explicit revision is
484+// left for StartDeploy to report, and a missing desired head means a
485+// first deploy with nothing captured yet.
486+func (s *AppServiceImpl) loadScopeRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error) {
487+ if revision != "" {
488+ rev, err := s.store.LoadRevision(ctx, app, revision)
489+ if err != nil {
490+ if errors.Is(err, domain.ErrAppRevisionNotFound) {
491+ return domain.AppDesiredRevision{}, nil
492+ }
493+ return domain.AppDesiredRevision{}, fmt.Errorf("apps: load revision %q of app %q for service scope: %w", revision, app, err)
494+ }
495+ return rev, nil
496+ }
497+ desired, ok, err := s.store.LoadDesired(ctx, app)
498+ if err != nil {
499+ return domain.AppDesiredRevision{}, fmt.Errorf("apps: load desired of app %q for service scope: %w", app, err)
500+ }
501+ if !ok {
502+ return domain.AppDesiredRevision{}, nil
503+ }
504+ return desired, nil
505+}
506+
430507 // beginDeploy enrolls one Deploy hand-off before the engine claims anything.
431508 // It refuses the request once Shutdown began, and otherwise guarantees Shutdown
432509 // waits for the hand-off (claim, schedule, or settle) to finish before state is
@@ -461,7 +538,10 @@ func (s *AppServiceImpl) Start(ctx context.Context, app, idempotencyKey string)
461538 }
462539
463540 // Restart implements in.AppService.
464-func (s *AppServiceImpl) Restart(ctx context.Context, app, service, idempotencyKey string) (*domain.AppOperation, error) {
541+func (s *AppServiceImpl) Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
542+ if err := s.requireServiceScope(ctx, app, "", service, all, false); err != nil {
543+ return nil, err
544+ }
465545 result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)
466546 if result == nil {
467547 return nil, err
@@ -2,8 +2,10 @@ package apps
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "sort"7 "sort"
8+ "strings"
7 "sync"9 "sync"
8 10
9 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -405,7 +407,10 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff,
405 // cancellation cannot abort an in-flight replacement. A repeated idempotency407 // cancellation cannot abort an in-flight replacement. A repeated idempotency
406 // key is never owned twice: its stored journal replays instead of spawning a408 // key is never owned twice: its stored journal replays instead of spawning a
407 // second execution, and only the owner schedules any work.409 // second execution, and only the owner schedules any work.
408-func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) {410+func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
411+ if err := s.requireServiceScope(ctx, app, revision, service, all, true); err != nil {
412+ return nil, err
413+ }
409 done, err := s.beginDeploy()414 done, err := s.beginDeploy()
410 if err != nil {415 if err != nil {
411 return nil, err416 return nil, err
@@ -427,6 +432,78 @@ func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, ide
427 return s.operationResult(ctx, app, started.Claim.Op, nil)432 return s.operationResult(ctx, app, started.Claim.Op, nil)
428 }433 }
429 434
435+// requireServiceScope refuses an app-wide deploy or restart of a
436+// multi-service app unless the caller asked for it with all, so an
437+// unrelated service (a database, say) is never replaced by accident. It
438+// runs before any claim or idempotency key is recorded. Deploy counts the
439+// selected revision — an explicit revision when one is given, the desired
440+// head otherwise — plus ACTIVE, so an older multi-service revision cannot
441+// slip through a single-service head, and a first deploy is covered.
442+// Restart counts ACTIVE only.
443+func (s *AppServiceImpl) requireServiceScope(ctx context.Context, app, revision, service string, all, includeDesired bool) error {
444+ if all && service != "" {
445+ return fmt.Errorf("apps: --all and --service are mutually exclusive: %w", domain.ErrAppServiceScope)
446+ }
447+ if all || service != "" {
448+ return nil
449+ }
450+ names := map[string]struct{}{}
451+ if includeDesired {
452+ desired, err := s.loadScopeRevision(ctx, app, revision)
453+ if err != nil {
454+ return err
455+ }
456+ for _, svc := range desired.Spec.Services {
457+ names[svc.Name] = struct{}{}
458+ }
459+ }
460+ active, ok, err := s.store.LoadActive(ctx, app)
461+ if err != nil {
462+ return fmt.Errorf("apps: load active of app %q for service scope: %w", app, err)
463+ }
464+ if ok {
465+ for name := range active.Services {
466+ names[name] = struct{}{}
467+ }
468+ }
469+ if len(names) <= 1 {
470+ return nil
471+ }
472+ sorted := make([]string, 0, len(names))
473+ for name := range names {
474+ sorted = append(sorted, name)
475+ }
476+ sort.Strings(sorted)
477+ return fmt.Errorf("app %s has several services (%s): pass --service NAME or --all: %w",
478+ app, strings.Join(sorted, ", "), domain.ErrAppServiceScope)
479+}
480+
481+// loadScopeRevision returns the revision a deploy would activate: the
482+// explicit revision when one is selected, the desired head otherwise. A
483+// missing revision counts no services: an unknown explicit revision is
484+// left for StartDeploy to report, and a missing desired head means a
485+// first deploy with nothing captured yet.
486+func (s *AppServiceImpl) loadScopeRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error) {
487+ if revision != "" {
488+ rev, err := s.store.LoadRevision(ctx, app, revision)
489+ if err != nil {
490+ if errors.Is(err, domain.ErrAppRevisionNotFound) {
491+ return domain.AppDesiredRevision{}, nil
492+ }
493+ return domain.AppDesiredRevision{}, fmt.Errorf("apps: load revision %q of app %q for service scope: %w", revision, app, err)
494+ }
495+ return rev, nil
496+ }
497+ desired, ok, err := s.store.LoadDesired(ctx, app)
498+ if err != nil {
499+ return domain.AppDesiredRevision{}, fmt.Errorf("apps: load desired of app %q for service scope: %w", app, err)
500+ }
501+ if !ok {
502+ return domain.AppDesiredRevision{}, nil
503+ }
504+ return desired, nil
505+}
506+
430 // beginDeploy enrolls one Deploy hand-off before the engine claims anything.507 // beginDeploy enrolls one Deploy hand-off before the engine claims anything.
431 // It refuses the request once Shutdown began, and otherwise guarantees Shutdown508 // It refuses the request once Shutdown began, and otherwise guarantees Shutdown
432 // waits for the hand-off (claim, schedule, or settle) to finish before state is509 // waits for the hand-off (claim, schedule, or settle) to finish before state is
@@ -461,7 +538,10 @@ func (s *AppServiceImpl) Start(ctx context.Context, app, idempotencyKey string)
461 }538 }
462 539
463 // Restart implements in.AppService.540 // Restart implements in.AppService.
464-func (s *AppServiceImpl) Restart(ctx context.Context, app, service, idempotencyKey string) (*domain.AppOperation, error) {541+func (s *AppServiceImpl) Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
542+ if err := s.requireServiceScope(ctx, app, "", service, all, false); err != nil {
543+ return nil, err
544+ }
465 result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)545 result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)
466 if result == nil {546 if result == nil {
467 return nil, err547 return nil, err
M internal/usecase/apps/deploy_lifecycle_test.go +1 -1
@@ -29,7 +29,7 @@ func callDeploy(t *testing.T, svc *apps.AppServiceImpl, ctx context.Context, key
2929 t.Helper()
3030 out := make(chan deployCall, 1)
3131 go func() {
32- op, err := svc.Deploy(ctx, "blog", "rev-1", "web", key)
32+ op, err := svc.Deploy(ctx, "blog", "rev-1", "web", false, key)
3333 out <- deployCall{op: op, err: err}
3434 }()
3535 return out
@@ -29,7 +29,7 @@ func callDeploy(t *testing.T, svc *apps.AppServiceImpl, ctx context.Context, key
29 t.Helper()29 t.Helper()
30 out := make(chan deployCall, 1)30 out := make(chan deployCall, 1)
31 go func() {31 go func() {
32- op, err := svc.Deploy(ctx, "blog", "rev-1", "web", key)32+ op, err := svc.Deploy(ctx, "blog", "rev-1", "web", false, key)
33 out <- deployCall{op: op, err: err}33 out <- deployCall{op: op, err: err}
34 }()34 }()
35 return out35 return out
A internal/usecase/apps/service_scope_test.go +145 -0
@@ -0,0 +1,145 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+ "github.com/bnema/gordon/internal/usecase/apps"
14+ "github.com/bnema/gordon/internal/usecase/deployment"
15+)
16+
17+func scopeDesired(names ...string) domain.AppDesiredRevision {
18+ rev := domain.AppDesiredRevision{Revision: "rev-1", App: "blog"}
19+ for _, name := range names {
20+ rev.Spec.Services = append(rev.Spec.Services, domain.AppService{Name: name})
21+ }
22+ return rev
23+}
24+
25+func scopeRevision(revision string, names ...string) domain.AppDesiredRevision {
26+ rev := scopeDesired(names...)
27+ rev.Revision = revision
28+ return rev
29+}
30+
31+func scopeActive(names ...string) domain.AppActive {
32+ active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{}}
33+ for _, name := range names {
34+ active.Services[name] = domain.AppEffectiveService{}
35+ }
36+ return active
37+}
38+
39+func TestAppServiceImpl_Deploy_MultiServiceRequiresScope(t *testing.T) {
40+ store := newMockAppState(t)
41+ deploy := newMockDeployEngine(t)
42+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
43+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
44+
45+ // A first deploy has no ACTIVE yet: desired services still count.
46+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(scopeDesired("web", "db"), true, nil)
47+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil)
48+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
49+ t.Fatal("no claim may be made without a service scope")
50+ return nil, nil
51+ }
52+
53+ op, err := svc.Deploy(context.Background(), "blog", "", "", false, "key-1")
54+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
55+ assert.Nil(t, op)
56+ assert.Contains(t, err.Error(), "app blog has several services (db, web): pass --service NAME or --all")
57+}
58+
59+func TestAppServiceImpl_Deploy_OldRevisionWithMultipleServicesRequiresScope(t *testing.T) {
60+ store := newMockAppState(t)
61+ deploy := newMockDeployEngine(t)
62+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
63+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
64+
65+ // The head and ACTIVE know only one service, but the revision the
66+ // deploy would activate declares two: the scope check must count the
67+ // selected revision, not the head.
68+ store.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(scopeRevision("rev-2", "web", "db"), nil)
69+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web"), true, nil)
70+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
71+ t.Fatal("no claim may be made without a service scope")
72+ return nil, nil
73+ }
74+
75+ op, err := svc.Deploy(context.Background(), "blog", "rev-2", "", false, "key-1")
76+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
77+ assert.Nil(t, op)
78+ assert.Contains(t, err.Error(), "app blog has several services (db, web): pass --service NAME or --all")
79+}
80+
81+func TestAppServiceImpl_Deploy_SingleServiceNeedsNoScope(t *testing.T) {
82+ store := newMockAppState(t)
83+ deploy := newMockDeployEngine(t)
84+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
85+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
86+
87+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(scopeDesired("web"), true, nil)
88+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web"), true, nil)
89+ started := make(chan struct{}, 1)
90+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
91+ started <- struct{}{}
92+ return nil, domain.ErrAppStateConflict
93+ }
94+
95+ _, err := svc.Deploy(context.Background(), "blog", "", "", false, "key-1")
96+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
97+ require.Len(t, started, 1, "a single-service app deploys without --service or --all")
98+}
99+
100+func TestAppServiceImpl_Deploy_AllSkipsScopeCheck(t *testing.T) {
101+ store := newMockAppState(t)
102+ deploy := newMockDeployEngine(t)
103+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
104+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
105+
106+ var got deployment.DeployInput
107+ deploy.startDeployFn = func(_ context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) {
108+ got = input
109+ return nil, domain.ErrAppStateConflict
110+ }
111+
112+ _, err := svc.Deploy(context.Background(), "blog", "", "", true, "key-1")
113+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
114+ assert.Equal(t, deployment.DeployInput{App: "blog", Op: "key-1"}, got, "--all deploys every service")
115+}
116+
117+func TestAppServiceImpl_Deploy_AllAndServiceAreExclusive(t *testing.T) {
118+ svc := apps.NewAppServiceImpl(newMockAppState(t), newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
119+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
120+
121+ _, err := svc.Deploy(context.Background(), "blog", "", "web", true, "key-1")
122+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
123+}
124+
125+func TestAppServiceImpl_Restart_MultiServiceRequiresScope(t *testing.T) {
126+ store := newMockAppState(t)
127+ svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
128+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
129+
130+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web", "db"), true, nil)
131+
132+ // The engine mock has no Restart expectation: reaching it fails the test.
133+ op, err := svc.Restart(context.Background(), "blog", "", false, "key-1")
134+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
135+ assert.Nil(t, op)
136+ assert.Contains(t, err.Error(), "(db, web)")
137+}
138+
139+func TestAppServiceImpl_Restart_AllAndServiceAreExclusive(t *testing.T) {
140+ svc := apps.NewAppServiceImpl(newMockAppState(t), newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
141+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
142+
143+ _, err := svc.Restart(context.Background(), "blog", "web", true, "key-1")
144+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
145+}
@@ -0,0 +1,145 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+ "github.com/bnema/gordon/internal/usecase/apps"
14+ "github.com/bnema/gordon/internal/usecase/deployment"
15+)
16+
17+func scopeDesired(names ...string) domain.AppDesiredRevision {
18+ rev := domain.AppDesiredRevision{Revision: "rev-1", App: "blog"}
19+ for _, name := range names {
20+ rev.Spec.Services = append(rev.Spec.Services, domain.AppService{Name: name})
21+ }
22+ return rev
23+}
24+
25+func scopeRevision(revision string, names ...string) domain.AppDesiredRevision {
26+ rev := scopeDesired(names...)
27+ rev.Revision = revision
28+ return rev
29+}
30+
31+func scopeActive(names ...string) domain.AppActive {
32+ active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{}}
33+ for _, name := range names {
34+ active.Services[name] = domain.AppEffectiveService{}
35+ }
36+ return active
37+}
38+
39+func TestAppServiceImpl_Deploy_MultiServiceRequiresScope(t *testing.T) {
40+ store := newMockAppState(t)
41+ deploy := newMockDeployEngine(t)
42+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
43+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
44+
45+ // A first deploy has no ACTIVE yet: desired services still count.
46+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(scopeDesired("web", "db"), true, nil)
47+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil)
48+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
49+ t.Fatal("no claim may be made without a service scope")
50+ return nil, nil
51+ }
52+
53+ op, err := svc.Deploy(context.Background(), "blog", "", "", false, "key-1")
54+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
55+ assert.Nil(t, op)
56+ assert.Contains(t, err.Error(), "app blog has several services (db, web): pass --service NAME or --all")
57+}
58+
59+func TestAppServiceImpl_Deploy_OldRevisionWithMultipleServicesRequiresScope(t *testing.T) {
60+ store := newMockAppState(t)
61+ deploy := newMockDeployEngine(t)
62+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
63+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
64+
65+ // The head and ACTIVE know only one service, but the revision the
66+ // deploy would activate declares two: the scope check must count the
67+ // selected revision, not the head.
68+ store.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(scopeRevision("rev-2", "web", "db"), nil)
69+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web"), true, nil)
70+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
71+ t.Fatal("no claim may be made without a service scope")
72+ return nil, nil
73+ }
74+
75+ op, err := svc.Deploy(context.Background(), "blog", "rev-2", "", false, "key-1")
76+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
77+ assert.Nil(t, op)
78+ assert.Contains(t, err.Error(), "app blog has several services (db, web): pass --service NAME or --all")
79+}
80+
81+func TestAppServiceImpl_Deploy_SingleServiceNeedsNoScope(t *testing.T) {
82+ store := newMockAppState(t)
83+ deploy := newMockDeployEngine(t)
84+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
85+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
86+
87+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(scopeDesired("web"), true, nil)
88+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web"), true, nil)
89+ started := make(chan struct{}, 1)
90+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
91+ started <- struct{}{}
92+ return nil, domain.ErrAppStateConflict
93+ }
94+
95+ _, err := svc.Deploy(context.Background(), "blog", "", "", false, "key-1")
96+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
97+ require.Len(t, started, 1, "a single-service app deploys without --service or --all")
98+}
99+
100+func TestAppServiceImpl_Deploy_AllSkipsScopeCheck(t *testing.T) {
101+ store := newMockAppState(t)
102+ deploy := newMockDeployEngine(t)
103+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
104+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
105+
106+ var got deployment.DeployInput
107+ deploy.startDeployFn = func(_ context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) {
108+ got = input
109+ return nil, domain.ErrAppStateConflict
110+ }
111+
112+ _, err := svc.Deploy(context.Background(), "blog", "", "", true, "key-1")
113+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
114+ assert.Equal(t, deployment.DeployInput{App: "blog", Op: "key-1"}, got, "--all deploys every service")
115+}
116+
117+func TestAppServiceImpl_Deploy_AllAndServiceAreExclusive(t *testing.T) {
118+ svc := apps.NewAppServiceImpl(newMockAppState(t), newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
119+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
120+
121+ _, err := svc.Deploy(context.Background(), "blog", "", "web", true, "key-1")
122+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
123+}
124+
125+func TestAppServiceImpl_Restart_MultiServiceRequiresScope(t *testing.T) {
126+ store := newMockAppState(t)
127+ svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
128+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
129+
130+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(scopeActive("web", "db"), true, nil)
131+
132+ // The engine mock has no Restart expectation: reaching it fails the test.
133+ op, err := svc.Restart(context.Background(), "blog", "", false, "key-1")
134+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
135+ assert.Nil(t, op)
136+ assert.Contains(t, err.Error(), "(db, web)")
137+}
138+
139+func TestAppServiceImpl_Restart_AllAndServiceAreExclusive(t *testing.T) {
140+ svc := apps.NewAppServiceImpl(newMockAppState(t), newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
141+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
142+
143+ _, err := svc.Restart(context.Background(), "blog", "web", true, "key-1")
144+ require.ErrorIs(t, err, domain.ErrAppServiceScope)
145+}

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