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 Unverified
B5690EEEBB952194The key is unknown
20 files · +432 -88
M docs/cli/apps.md +18 -4
| @@ -246,10 +246,23 @@ Deletes the registered value for `KEY`. | |||
| 246 | 246 | ## gordon apps deploy | |
| 247 | 247 | ||
| 248 | 248 | ```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 | 266 | the first failure stops the deploy, successful services are preserved, | |
| 254 | 267 | later services stay unchanged. | |
| 255 | 268 | ||
| @@ -266,10 +279,11 @@ local polling (the daemon-side operation keeps running) and prints the | |||
| 266 | 279 | ## gordon apps restart | |
| 267 | 280 | ||
| 268 | 281 | ```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 | ||
| @@ -246,10 +246,23 @@ Deletes the registered value for `KEY`. | |||||
| 246 | ## gordon apps deploy | 246 | ## gordon apps deploy | ||
| 247 | 247 | ||||
| 248 | ```bash | 248 | ```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 restart | 279 | ## gordon apps restart | ||
| 267 | 280 | ||||
| 268 | ```bash | 281 | ```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 { | |||
| 47 | 47 | type AppDeployRequest struct { | |
| 48 | 48 | Revision string `json:"revision,omitempty"` | |
| 49 | 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 | 54 | // 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 { | |||
| 219 | 219 | // that the daemon-side operation keeps running. A terminal partial/failed | |
| 220 | 220 | // outcome exits nonzero without hiding the successful apply. | |
| 221 | 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 | 223 | if err != nil { | |
| 224 | 224 | if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok { | |
| 225 | 225 | return applySucceededError(apply, conflictErr) | |
| @@ -241,7 +241,7 @@ func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppA | |||
| 241 | 241 | // running document is emitted, and progress, transient warnings, and Ctrl-C | |
| 242 | 242 | // resume guidance go to errOut so stdout stays machine-readable. | |
| 243 | 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 | 245 | if err != nil { | |
| 246 | 246 | var conflict *remote.AppOpConflictError | |
| 247 | 247 | if errors.As(err, &conflict) { | |
| @@ -770,6 +770,7 @@ func readSecretStdin(in io.Reader) ([]string, error) { | |||
| 770 | 770 | func newAppDeployCmd() *cobra.Command { | |
| 771 | 771 | var revision string | |
| 772 | 772 | var service string | |
| 773 | + | var all bool | |
| 773 | 774 | var jsonOut bool | |
| 774 | 775 | cmd := &cobra.Command{ | |
| 775 | 776 | Use: "deploy APP", | |
| @@ -782,11 +783,14 @@ func newAppDeployCmd() *cobra.Command { | |||
| 782 | 783 | } | |
| 783 | 784 | defer handle.close() | |
| 784 | 785 | 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 | 790 | cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)") | |
| 789 | 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 | 794 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 791 | 795 | return cmd | |
| 792 | 796 | } | |
| @@ -794,8 +798,8 @@ func newAppDeployCmd() *cobra.Command { | |||
| 794 | 798 | // runAppDeploy issues one deploy mutation and, when the daemon answers 202 | |
| 795 | 799 | // with a running journal, polls the existing by-key endpoint to terminal | |
| 796 | 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 { | |
| 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) | |
| 799 | 803 | if err != nil { | |
| 800 | 804 | if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok { | |
| 801 | 805 | return conflictErr | |
| @@ -814,6 +818,7 @@ func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, servic | |||
| 814 | 818 | // newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve. | |
| 815 | 819 | func newAppRestartCmd() *cobra.Command { | |
| 816 | 820 | var service string | |
| 821 | + | var all bool | |
| 817 | 822 | var jsonOut bool | |
| 818 | 823 | cmd := &cobra.Command{ | |
| 819 | 824 | Use: "restart APP", | |
| @@ -826,16 +831,18 @@ func newAppRestartCmd() *cobra.Command { | |||
| 826 | 831 | } | |
| 827 | 832 | defer handle.close() | |
| 828 | 833 | 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 | 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 | 840 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 834 | 841 | return cmd | |
| 835 | 842 | } | |
| 836 | 843 | ||
| 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) | |
| 839 | 846 | if err != nil { | |
| 840 | 847 | if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok { | |
| 841 | 848 | return conflictErr | |
| @@ -219,7 +219,7 @@ type appApplyDeployDocument struct { | |||||
| 219 | // that the daemon-side operation keeps running. A terminal partial/failed | 219 | // 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-C | 241 | // 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.AppOpConflictError | 246 | 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 string | 771 | var revision string | ||
| 772 | var service string | 772 | var service string | ||
| 773 | + | var all bool | |||
| 773 | var jsonOut bool | 774 | 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.plane | 785 | 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 cmd | 795 | return cmd | ||
| 792 | } | 796 | } | ||
| @@ -794,8 +798,8 @@ func newAppDeployCmd() *cobra.Command { | |||||
| 794 | // runAppDeploy issues one deploy mutation and, when the daemon answers 202 | 798 | // runAppDeploy issues one deploy mutation and, when the daemon answers 202 | ||
| 795 | // with a running journal, polls the existing by-key endpoint to terminal | 799 | // 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 conflictErr | 805 | 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 string | 820 | var service string | ||
| 821 | + | var all bool | |||
| 817 | var jsonOut bool | 822 | 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.plane | 833 | 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 cmd | 841 | 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 conflictErr | 848 | return conflictErr | ||
M internal/adapters/in/cli/apps_test.go +6 -6
| @@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) { | |||
| 204 | 204 | plane := appPlane(t) | |
| 205 | 205 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |
| 206 | 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 | 208 | require.Error(t, err) | |
| 209 | 209 | assert.Contains(t, err.Error(), "outcome-unknown") | |
| 210 | 210 | assert.Contains(t, err.Error(), "key-abc") | |
| @@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) { | |||
| 223 | 223 | }, | |
| 224 | 224 | }).Once() | |
| 225 | 225 | 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 | 227 | require.Error(t, err, "conflict must retain failure semantics") | |
| 228 | 228 | text := out.String() | |
| 229 | 229 | assert.Contains(t, text, "web:") | |
| @@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) { | |||
| 246 | 246 | }, | |
| 247 | 247 | }).Once() | |
| 248 | 248 | 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 | 250 | require.Error(t, err) | |
| 251 | 251 | var got dto.AppDeployResponse | |
| 252 | 252 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| @@ -440,16 +440,16 @@ func TestRunAppsSecretsDelete(t *testing.T) { | |||
| 440 | 440 | func TestRunAppRestart_AndLifecycle(t *testing.T) { | |
| 441 | 441 | resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"} | |
| 442 | 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 | 444 | plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 445 | 445 | plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 446 | 446 | plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once() | |
| 447 | 447 | 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 | 449 | assert.Contains(t, out.String(), "success") | |
| 450 | 450 | ||
| 451 | 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 | 453 | var got dto.AppDeployResponse | |
| 454 | 454 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 455 | 455 | 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.Buffer | 225 | 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.Buffer | 248 | 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.AppDeployResponse | 251 | 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.Buffer | 447 | 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.AppDeployResponse | 453 | 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) { | |||
| 46 | 46 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |
| 47 | 47 | ||
| 48 | 48 | 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 | 50 | text := out.String() | |
| 51 | 51 | assert.Contains(t, text, "op op-1:") | |
| 52 | 52 | assert.Contains(t, text, "running") | |
| @@ -69,7 +69,7 @@ func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) { | |||
| 69 | 69 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |
| 70 | 70 | ||
| 71 | 71 | 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 | 73 | assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"), | |
| 74 | 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 | 84 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |
| 85 | 85 | ||
| 86 | 86 | 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 | 90 | func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) { | |
| @@ -99,7 +99,7 @@ func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) { | |||
| 99 | 99 | }, nil).Once() | |
| 100 | 100 | ||
| 101 | 101 | 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 | 103 | require.Error(t, err, "a terminal partial outcome must exit nonzero") | |
| 104 | 104 | assert.Contains(t, err.Error(), "partial") | |
| 105 | 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 | 116 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |
| 117 | 117 | ||
| 118 | 118 | 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 | 121 | dec := json.NewDecoder(bytes.NewReader(out.Bytes())) | |
| 122 | 122 | 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.Buffer | 48 | 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.Buffer | 71 | 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.Buffer | 86 | 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.Buffer | 101 | 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.Buffer | 118 | 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.AppDeployResponse | 122 | var got dto.AppDeployResponse | ||
M internal/adapters/in/cli/controlplane.go +1 -1
| @@ -20,7 +20,7 @@ type ControlPlane interface { | |||
| 20 | 20 | DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) | |
| 21 | 21 | StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 22 | 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 | 24 | RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) | |
| 25 | 25 | OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) | |
| 26 | 26 | 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 | |||
| 48 | 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) { | |
| 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) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | 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 | |||
| 1560 | 1560 | } | |
| 1561 | 1561 | ||
| 1562 | 1562 | // 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) | |
| 1565 | 1565 | ||
| 1566 | 1566 | if len(ret) == 0 { | |
| 1567 | 1567 | panic("no return value specified for RestartApp") | |
| @@ -1570,23 +1570,23 @@ func (_mock *MockControlPlane) RestartApp(ctx context.Context, app string, servi | |||
| 1570 | 1570 | var r0 *dto.AppDeployResponse | |
| 1571 | 1571 | var r1 string | |
| 1572 | 1572 | 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) | |
| 1575 | 1575 | } | |
| 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) | |
| 1578 | 1578 | } else { | |
| 1579 | 1579 | if ret.Get(0) != nil { | |
| 1580 | 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 { | |
| 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) | |
| 1585 | 1585 | } else { | |
| 1586 | 1586 | r1 = ret.Get(1).(string) | |
| 1587 | 1587 | } | |
| 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) | |
| 1590 | 1590 | } else { | |
| 1591 | 1591 | r2 = ret.Error(2) | |
| 1592 | 1592 | } | |
| @@ -1602,11 +1602,12 @@ type MockControlPlane_RestartApp_Call struct { | |||
| 1602 | 1602 | // - ctx context.Context | |
| 1603 | 1603 | // - app string | |
| 1604 | 1604 | // - 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)} | |
| 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 | 1611 | _c.Call.Run(func(args mock.Arguments) { | |
| 1611 | 1612 | var arg0 context.Context | |
| 1612 | 1613 | if args[0] != nil { | |
| @@ -1620,10 +1621,15 @@ func (_c *MockControlPlane_RestartApp_Call) Run(run func(ctx context.Context, ap | |||
| 1620 | 1621 | if args[2] != nil { | |
| 1621 | 1622 | arg2 = args[2].(string) | |
| 1622 | 1623 | } | |
| 1624 | + | var arg3 bool | |
| 1625 | + | if args[3] != nil { | |
| 1626 | + | arg3 = args[3].(bool) | |
| 1627 | + | } | |
| 1623 | 1628 | run( | |
| 1624 | 1629 | arg0, | |
| 1625 | 1630 | arg1, | |
| 1626 | 1631 | arg2, | |
| 1632 | + | arg3, | |
| 1627 | 1633 | ) | |
| 1628 | 1634 | }) | |
| 1629 | 1635 | return _c | |
| @@ -1634,7 +1640,7 @@ func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDep | |||
| 1634 | 1640 | 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 | 1644 | _c.Call.Return(run) | |
| 1639 | 1645 | return _c | |
| 1640 | 1646 | } | |
| @@ -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 MockControlPlane | 1562 | // 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.AppDeployResponse | 1570 | var r0 *dto.AppDeployResponse | ||
| 1571 | var r1 string | 1571 | var r1 string | ||
| 1572 | var r2 error | 1572 | 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.Context | 1602 | // - ctx context.Context | ||
| 1603 | // - app string | 1603 | // - app string | ||
| 1604 | // - service string | 1604 | // - 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.Context | 1612 | 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 _c | 1635 | return _c | ||
| @@ -1634,7 +1640,7 @@ func (_c *MockControlPlane_RestartApp_Call) Return(appDeployResponse *dto.AppDep | |||||
| 1634 | return _c | 1640 | 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 _c | 1645 | 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 | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 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 | 171 | key := newIdempotencyKey() | |
| 171 | 172 | path := "/apps/" + url.PathEscape(app) + "/restart" | |
| 173 | + | query := url.Values{} | |
| 172 | 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 | 183 | resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil) | |
| 176 | 184 | 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) { | |||
| 105 | 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 | 128 | // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts | |
| 109 | 129 | // the 202 running response: it decodes the journal and returns the key so the | |
| 110 | 130 | // 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 accepts | 128 | // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts | ||
| 109 | // the 202 running response: it decodes the journal and returns the key so the | 129 | // 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 | |||
| 303 | 303 | if !ok { | |
| 304 | 304 | 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 | 307 | if err != nil && (op == nil || isMappedPreflightError(err, op)) { | |
| 308 | 308 | h.sendAppOpError(w, err) | |
| 309 | 309 | return | |
| @@ -376,8 +376,9 @@ func (h *Handler) handleAppRestart(w http.ResponseWriter, r *http.Request, app s | |||
| 376 | 376 | if !ok { | |
| 377 | 377 | return | |
| 378 | 378 | } | |
| 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) | |
| 381 | 382 | if err != nil && op == nil { | |
| 382 | 383 | h.sendAppOpError(w, err) | |
| 383 | 384 | return | |
| @@ -557,6 +558,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) { | |||
| 557 | 558 | h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy") | |
| 558 | 559 | case errors.Is(err, domain.ErrAppUnmanagedImageVolume): | |
| 559 | 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 | 563 | case errors.Is(err, domain.ErrAppNotFound): | |
| 561 | 564 | h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name") | |
| 562 | 565 | 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 | return | 304 | 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 | return | 309 | return | ||
| @@ -376,8 +376,9 @@ func (h *Handler) handleAppRestart(w http.ResponseWriter, r *http.Request, app s | |||||
| 376 | if !ok { | 376 | if !ok { | ||
| 377 | return | 377 | 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 | return | 384 | 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) { | |||
| 131 | 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 | 135 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{ | |
| 136 | 136 | App: "blog", Converged: true, ConvergedRevision: "rev-1", | |
| 137 | 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 | 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 | 175 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once() | |
| 176 | 176 | ||
| 177 | 177 | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | |
| @@ -201,7 +201,7 @@ func TestHandler_AppDeploy_RunningReturns202(t *testing.T) { | |||
| 201 | 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 | 205 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |
| 206 | 206 | ||
| 207 | 207 | srv := newScopedTestServer(t, handler, "admin:apps:write") | |
| @@ -229,7 +229,7 @@ func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) { | |||
| 229 | 229 | Outcome: domain.AppOutcomeSuccess, | |
| 230 | 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 | 233 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |
| 234 | 234 | ||
| 235 | 235 | srv := newScopedTestServer(t, handler, "admin:apps:write") | |
| @@ -258,7 +258,7 @@ func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) { | |||
| 258 | 258 | }}, | |
| 259 | 259 | } | |
| 260 | 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 | 262 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |
| 263 | 263 | ||
| 264 | 264 | srv := newScopedTestServer(t, handler, "admin:apps:write") | |
| @@ -326,7 +326,7 @@ func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) { | |||
| 326 | 326 | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}, | |
| 327 | 327 | } | |
| 328 | 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 | 330 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |
| 331 | 331 | ||
| 332 | 332 | srv := newScopedTestServer(t, handler, "admin:apps:write") | |
| @@ -351,7 +351,7 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) { | |||
| 351 | 351 | }, | |
| 352 | 352 | } | |
| 353 | 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 | 355 | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |
| 356 | 356 | ||
| 357 | 357 | srv := newScopedTestServer(t, handler, "admin:apps:write") | |
| @@ -360,6 +360,45 @@ func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) { | |||
| 360 | 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 | 402 | // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request | |
| 364 | 403 | // validation contract: a deploy without a valid key never reaches the | |
| 365 | 404 | // 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 request | 402 | // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request | ||
| 364 | // validation contract: a deploy without a valid key never reaches the | 403 | // 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) | |||
| 77 | 77 | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once() | |
| 78 | 78 | // Show resolves no app: the response carries the journal alone. | |
| 79 | 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 | 84 | release := make(chan struct{}) | |
| 82 | 85 | 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.Once | 85 | var releaseOnce sync.Once | ||
M internal/app/run_app_lifecycle_test.go +1 -1
| @@ -85,7 +85,7 @@ func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing. | |||
| 85 | 85 | deployDone := make(chan struct{}) | |
| 86 | 86 | go func() { | |
| 87 | 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 | 91 | 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.Context | 91 | var execCtx context.Context | ||
M internal/boundaries/in/apps.go +6 -4
| @@ -35,8 +35,9 @@ type AppService interface { | |||
| 35 | 35 | Diff(ctx context.Context, app string) (domain.AppDiff, error) | |
| 36 | 36 | ||
| 37 | 37 | // 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) | |
| 40 | 41 | ||
| 41 | 42 | // Stop persists the durable stopped intent and stops exact containers. | |
| 42 | 43 | Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) | |
| @@ -45,8 +46,9 @@ type AppService interface { | |||
| 45 | 46 | Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) | |
| 46 | 47 | ||
| 47 | 48 | // 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) | |
| 50 | 52 | ||
| 51 | 53 | // Remove withdraws workloads; volumes and secrets are retained as | |
| 52 | 54 | // 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 the | 37 | // 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 as | 53 | // 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 | |||
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | 199 | // 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) | |
| 202 | 202 | ||
| 203 | 203 | if len(ret) == 0 { | |
| 204 | 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 | 207 | var r0 *domain.AppOperation | |
| 208 | 208 | 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) | |
| 211 | 211 | } | |
| 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) | |
| 214 | 214 | } else { | |
| 215 | 215 | if ret.Get(0) != nil { | |
| 216 | 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 { | |
| 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) | |
| 221 | 221 | } else { | |
| 222 | 222 | r1 = ret.Error(1) | |
| 223 | 223 | } | |
| @@ -234,12 +234,13 @@ type MockAppService_Deploy_Call struct { | |||
| 234 | 234 | // - app string | |
| 235 | 235 | // - revision string | |
| 236 | 236 | // - service string | |
| 237 | + | // - all bool | |
| 237 | 238 | // - 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)} | |
| 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 | 244 | _c.Call.Run(func(args mock.Arguments) { | |
| 244 | 245 | var arg0 context.Context | |
| 245 | 246 | if args[0] != nil { | |
| @@ -257,9 +258,13 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri | |||
| 257 | 258 | if args[3] != nil { | |
| 258 | 259 | arg3 = args[3].(string) | |
| 259 | 260 | } | |
| 260 | - | var arg4 string | |
| 261 | + | var arg4 bool | |
| 261 | 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 | 269 | run( | |
| 265 | 270 | arg0, | |
| @@ -267,6 +272,7 @@ func (_c *MockAppService_Deploy_Call) Run(run func(ctx context.Context, app stri | |||
| 267 | 272 | arg2, | |
| 268 | 273 | arg3, | |
| 269 | 274 | arg4, | |
| 275 | + | arg5, | |
| 270 | 276 | ) | |
| 271 | 277 | }) | |
| 272 | 278 | return _c | |
| @@ -277,7 +283,7 @@ func (_c *MockAppService_Deploy_Call) Return(appOperation *domain.AppOperation, | |||
| 277 | 283 | 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 | 287 | _c.Call.Return(run) | |
| 282 | 288 | return _c | |
| 283 | 289 | } | |
| @@ -633,8 +639,8 @@ func (_c *MockAppService_Remove_Call) RunAndReturn(run func(ctx context.Context, | |||
| 633 | 639 | } | |
| 634 | 640 | ||
| 635 | 641 | // 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) | |
| 638 | 644 | ||
| 639 | 645 | if len(ret) == 0 { | |
| 640 | 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 | 649 | var r0 *domain.AppOperation | |
| 644 | 650 | 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) | |
| 647 | 653 | } | |
| 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) | |
| 650 | 656 | } else { | |
| 651 | 657 | if ret.Get(0) != nil { | |
| 652 | 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 { | |
| 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) | |
| 657 | 663 | } else { | |
| 658 | 664 | r1 = ret.Error(1) | |
| 659 | 665 | } | |
| @@ -669,12 +675,13 @@ type MockAppService_Restart_Call struct { | |||
| 669 | 675 | // - ctx context.Context | |
| 670 | 676 | // - app string | |
| 671 | 677 | // - service string | |
| 678 | + | // - all bool | |
| 672 | 679 | // - 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)} | |
| 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 | 685 | _c.Call.Run(func(args mock.Arguments) { | |
| 679 | 686 | var arg0 context.Context | |
| 680 | 687 | if args[0] != nil { | |
| @@ -688,15 +695,20 @@ func (_c *MockAppService_Restart_Call) Run(run func(ctx context.Context, app str | |||
| 688 | 695 | if args[2] != nil { | |
| 689 | 696 | arg2 = args[2].(string) | |
| 690 | 697 | } | |
| 691 | - | var arg3 string | |
| 698 | + | var arg3 bool | |
| 692 | 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 | 706 | run( | |
| 696 | 707 | arg0, | |
| 697 | 708 | arg1, | |
| 698 | 709 | arg2, | |
| 699 | 710 | arg3, | |
| 711 | + | arg4, | |
| 700 | 712 | ) | |
| 701 | 713 | }) | |
| 702 | 714 | return _c | |
| @@ -707,7 +719,7 @@ func (_c *MockAppService_Restart_Call) Return(appOperation *domain.AppOperation, | |||
| 707 | 719 | 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 | 723 | _c.Call.Return(run) | |
| 712 | 724 | return _c | |
| 713 | 725 | } | |
| @@ -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 MockAppService | 199 | // 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.AppOperation | 207 | var r0 *domain.AppOperation | ||
| 208 | var r1 error | 208 | 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 string | 234 | // - app string | ||
| 235 | // - revision string | 235 | // - revision string | ||
| 236 | // - service string | 236 | // - service string | ||
| 237 | + | // - all bool | |||
| 237 | // - idempotencyKey string | 238 | // - 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.Context | 245 | 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 string | 261 | + | 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 _c | 278 | return _c | ||
| @@ -277,7 +283,7 @@ func (_c *MockAppService_Deploy_Call) Return(appOperation *domain.AppOperation, | |||||
| 277 | return _c | 283 | 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 _c | 288 | 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 MockAppService | 641 | // 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.AppOperation | 649 | var r0 *domain.AppOperation | ||
| 644 | var r1 error | 650 | 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.Context | 675 | // - ctx context.Context | ||
| 670 | // - app string | 676 | // - app string | ||
| 671 | // - service string | 677 | // - service string | ||
| 678 | + | // - all bool | |||
| 672 | // - idempotencyKey string | 679 | // - 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.Context | 686 | 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 string | 698 | + | 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 _c | 714 | return _c | ||
| @@ -707,7 +719,7 @@ func (_c *MockAppService_Restart_Call) Return(appOperation *domain.AppOperation, | |||||
| 707 | return _c | 719 | 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 _c | 724 | return _c | ||
| 713 | } | 725 | } | ||
M internal/domain/errors.go +3 -0
| @@ -102,6 +102,9 @@ var ( | |||
| 102 | 102 | ErrAppImageNotAllowed = errors.New("image reference not allowed by installation policy") | |
| 103 | 103 | ErrAppSecretMissing = errors.New("required app secret missing") | |
| 104 | 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 | 108 | // ErrPruneDisabled means prune could not establish a safe scope for | |
| 106 | 109 | // the requested operation at all (for example, its protection or | |
| 107 | 110 | // 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 for | 108 | // ErrPruneDisabled means prune could not establish a safe scope for | ||
| 106 | // the requested operation at all (for example, its protection or | 109 | // the requested operation at all (for example, its protection or | ||
| 107 | // runtime ports are not wired). It is never returned merely because | 110 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "sort" | |
| 8 | + | "strings" | |
| 7 | 9 | "sync" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/zerowrap" | |
| @@ -405,7 +407,10 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff, | |||
| 405 | 407 | // cancellation cannot abort an in-flight replacement. A repeated idempotency | |
| 406 | 408 | // key is never owned twice: its stored journal replays instead of spawning a | |
| 407 | 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 | 414 | done, err := s.beginDeploy() | |
| 410 | 415 | if err != nil { | |
| 411 | 416 | return nil, err | |
| @@ -427,6 +432,78 @@ func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, ide | |||
| 427 | 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 | 507 | // beginDeploy enrolls one Deploy hand-off before the engine claims anything. | |
| 431 | 508 | // It refuses the request once Shutdown began, and otherwise guarantees Shutdown | |
| 432 | 509 | // 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 | 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 | 545 | result, err := s.deploy.Restart(ctx, app, service, idempotencyKey) | |
| 466 | 546 | if result == nil { | |
| 467 | 547 | 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 idempotency | 407 | // cancellation cannot abort an in-flight replacement. A repeated idempotency | ||
| 406 | // key is never owned twice: its stored journal replays instead of spawning a | 408 | // 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, err | 416 | 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 Shutdown | 508 | // 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 is | 509 | // 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, err | 547 | 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 | |||
| 29 | 29 | t.Helper() | |
| 30 | 30 | out := make(chan deployCall, 1) | |
| 31 | 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 | 33 | out <- deployCall{op: op, err: err} | |
| 34 | 34 | }() | |
| 35 | 35 | 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 out | 35 | 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 | + | } | |||