fix(apps): explain that secrets require an applied service (#8)
Closes #3 ## Summary `gordon apps secrets set` and `delete` explain what to do when the secret registration is missing, instead of `service "runner" has no registered secrets`. - App with no applied manifest: `app "blog" has no applied manifest; run `gordon apps apply` first`. - Service not in the applied manifest: `service "runner" is not in the applied manifest of app "blog"; run `gordon apps apply` first`. - Undeclared key on `set`: points to `[services.<name>.secrets]` and `gordon apps apply`. - `delete` uses neutral wording and never suggests applying or declaring a secret. - Secret set/delete responses drop the deploy-oriented hint `set the secret, then redeploy`. Status stays `400 secret-missing`. - Docs state the order: apply, secrets set, deploy. Secrets for unapplied services are still refused. ## Checks - `go test ./...` - `golangci-lint run ./...` Reviewed-on: https://git.bnema.dev/bnema/gordon/pulls/8
- commit
- 3a7fcc7658346333432214687688d60c0dffeca6
- author
- bnema · · 2026-10-02 13:30 UTC
- parent
- 78f9cbc
- signature
- unsigned
5 files · +112 -14
M docs/cli/apps.md +3 -0
| @@ -211,6 +211,9 @@ Reads values in one of three ways: | |||
| 211 | 211 | `--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments. | |
| 212 | 212 | An empty value is rejected, so `KEY=` is invalid. | |
| 213 | 213 | ||
| 214 | + | The service and its secret keys must be declared in the manifest and applied | |
| 215 | + | first: `gordon apps apply` → `gordon apps secrets set` → `gordon apps deploy`. | |
| 216 | + | ||
| 214 | 217 | Running containers keep the values they were created with. Apply new values | |
| 215 | 218 | with `gordon apps deploy APP --service SVC`: deploy sees the changed secret and | |
| 216 | 219 | recreates the service. `restart` does not apply new values. | |
| @@ -211,6 +211,9 @@ Reads values in one of three ways: | |||||
| 211 | `--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments. | 211 | `--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments. | ||
| 212 | An empty value is rejected, so `KEY=` is invalid. | 212 | An empty value is rejected, so `KEY=` is invalid. | ||
| 213 | 213 | ||||
| 214 | + | The service and its secret keys must be declared in the manifest and applied | |||
| 215 | + | first: `gordon apps apply` → `gordon apps secrets set` → `gordon apps deploy`. | |||
| 216 | + | ||||
| 214 | Running containers keep the values they were created with. Apply new values | 217 | Running containers keep the values they were created with. Apply new values | ||
| 215 | with `gordon apps deploy APP --service SVC`: deploy sees the changed secret and | 218 | with `gordon apps deploy APP --service SVC`: deploy sees the changed secret and | ||
| 216 | recreates the service. `restart` does not apply new values. | 219 | recreates the service. `restart` does not apply new values. | ||
M internal/adapters/in/http/admin/handler_apps.go +13 -2
| @@ -468,7 +468,7 @@ func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, ap | |||
| 468 | 468 | return | |
| 469 | 469 | } | |
| 470 | 470 | if err := svc.SetSecrets(ctx, app, req.Service, req.Secrets); err != nil { | |
| 471 | - | h.sendAppOpError(w, err) | |
| 471 | + | h.sendAppSecretWriteError(w, err) | |
| 472 | 472 | return | |
| 473 | 473 | } | |
| 474 | 474 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"}) | |
| @@ -492,12 +492,23 @@ func (h *Handler) handleAppSecretsDelete(w http.ResponseWriter, r *http.Request, | |||
| 492 | 492 | return | |
| 493 | 493 | } | |
| 494 | 494 | if err := svc.DeleteSecret(ctx, app, req.Service, req.Key); err != nil { | |
| 495 | - | h.sendAppOpError(w, err) | |
| 495 | + | h.sendAppSecretWriteError(w, err) | |
| 496 | 496 | return | |
| 497 | 497 | } | |
| 498 | 498 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) | |
| 499 | 499 | } | |
| 500 | 500 | ||
| 501 | + | // sendAppSecretWriteError maps secret set/delete errors. A missing | |
| 502 | + | // registration is not fixed by setting the secret, so the deploy-oriented | |
| 503 | + | // hint of sendAppOpError is omitted; the message carries the next step. | |
| 504 | + | func (h *Handler) sendAppSecretWriteError(w http.ResponseWriter, err error) { | |
| 505 | + | if errors.Is(err, domain.ErrAppSecretMissing) { | |
| 506 | + | h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "") | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | h.sendAppOpError(w, err) | |
| 510 | + | } | |
| 511 | + | ||
| 501 | 512 | // sendAppError writes the v3 error envelope (never carries logs). | |
| 502 | 513 | func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, cause, hint string) { | |
| 503 | 514 | w.Header().Set("Content-Type", "application/json") | |
| @@ -468,7 +468,7 @@ func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, ap | |||||
| 468 | return | 468 | return | ||
| 469 | } | 469 | } | ||
| 470 | if err := svc.SetSecrets(ctx, app, req.Service, req.Secrets); err != nil { | 470 | if err := svc.SetSecrets(ctx, app, req.Service, req.Secrets); err != nil { | ||
| 471 | - | h.sendAppOpError(w, err) | 471 | + | h.sendAppSecretWriteError(w, err) |
| 472 | return | 472 | return | ||
| 473 | } | 473 | } | ||
| 474 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"}) | 474 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"}) | ||
| @@ -492,12 +492,23 @@ func (h *Handler) handleAppSecretsDelete(w http.ResponseWriter, r *http.Request, | |||||
| 492 | return | 492 | return | ||
| 493 | } | 493 | } | ||
| 494 | if err := svc.DeleteSecret(ctx, app, req.Service, req.Key); err != nil { | 494 | if err := svc.DeleteSecret(ctx, app, req.Service, req.Key); err != nil { | ||
| 495 | - | h.sendAppOpError(w, err) | 495 | + | h.sendAppSecretWriteError(w, err) |
| 496 | return | 496 | return | ||
| 497 | } | 497 | } | ||
| 498 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) | 498 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) | ||
| 499 | } | 499 | } | ||
| 500 | 500 | ||||
| 501 | + | // sendAppSecretWriteError maps secret set/delete errors. A missing | |||
| 502 | + | // registration is not fixed by setting the secret, so the deploy-oriented | |||
| 503 | + | // hint of sendAppOpError is omitted; the message carries the next step. | |||
| 504 | + | func (h *Handler) sendAppSecretWriteError(w http.ResponseWriter, err error) { | |||
| 505 | + | if errors.Is(err, domain.ErrAppSecretMissing) { | |||
| 506 | + | h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "") | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | h.sendAppOpError(w, err) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 501 | // sendAppError writes the v3 error envelope (never carries logs). | 512 | // sendAppError writes the v3 error envelope (never carries logs). | ||
| 502 | func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, cause, hint string) { | 513 | func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, cause, hint string) { | ||
| 503 | w.Header().Set("Content-Type", "application/json") | 514 | w.Header().Set("Content-Type", "application/json") | ||
M internal/adapters/in/http/admin/handler_apps_test.go +13 -0
| @@ -441,6 +441,19 @@ func TestHandler_AppSecretsSet_Forwards(t *testing.T) { | |||
| 441 | 441 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | + | func TestHandler_AppSecretsSet_MissingRegistrationHasNoDeployHint(t *testing.T) { | |
| 445 | + | appSvc := inmocks.NewMockAppService(t) | |
| 446 | + | handler := appsTestHandler(t, appSvc) | |
| 447 | + | ||
| 448 | + | appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "runner", map[string]string{"K": "v"}). | |
| 449 | + | Return(fmt.Errorf("apps: service not applied: %w", domain.ErrAppSecretMissing)).Once() | |
| 450 | + | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set", | |
| 451 | + | dto.AppSecretSetRequest{Service: "runner", Secrets: map[string]string{"K": "v"}}, "admin:apps:write") | |
| 452 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 453 | + | assert.Contains(t, rec.Body.String(), "secret-missing") | |
| 454 | + | assert.NotContains(t, rec.Body.String(), "redeploy") | |
| 455 | + | } | |
| 456 | + | ||
| 444 | 457 | // TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle | |
| 445 | 458 | // mutation of a name with no app identity is a 404, not a 500 or a silent | |
| 446 | 459 | // success. | |
| @@ -441,6 +441,19 @@ func TestHandler_AppSecretsSet_Forwards(t *testing.T) { | |||||
| 441 | assert.Equal(t, http.StatusOK, rec.Code) | 441 | assert.Equal(t, http.StatusOK, rec.Code) | ||
| 442 | } | 442 | } | ||
| 443 | 443 | ||||
| 444 | + | func TestHandler_AppSecretsSet_MissingRegistrationHasNoDeployHint(t *testing.T) { | |||
| 445 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 446 | + | handler := appsTestHandler(t, appSvc) | |||
| 447 | + | ||||
| 448 | + | appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "runner", map[string]string{"K": "v"}). | |||
| 449 | + | Return(fmt.Errorf("apps: service not applied: %w", domain.ErrAppSecretMissing)).Once() | |||
| 450 | + | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set", | |||
| 451 | + | dto.AppSecretSetRequest{Service: "runner", Secrets: map[string]string{"K": "v"}}, "admin:apps:write") | |||
| 452 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 453 | + | assert.Contains(t, rec.Body.String(), "secret-missing") | |||
| 454 | + | assert.NotContains(t, rec.Body.String(), "redeploy") | |||
| 455 | + | } | |||
| 456 | + | ||||
| 444 | // TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle | 457 | // TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle | ||
| 445 | // mutation of a name with no app identity is a 404, not a 500 or a silent | 458 | // mutation of a name with no app identity is a 404, not a 500 or a silent | ||
| 446 | // success. | 459 | // success. | ||
M internal/usecase/apps/app_service.go +20 -12
| @@ -655,14 +655,14 @@ func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, va | |||
| 655 | 655 | if service == "" { | |
| 656 | 656 | return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec) | |
| 657 | 657 | } | |
| 658 | - | registered, err := s.secretNames(ctx, app, service) | |
| 658 | + | registered, err := s.secretNames(ctx, app, service, "; run `gordon apps apply` first") | |
| 659 | 659 | if err != nil { | |
| 660 | 660 | return err | |
| 661 | 661 | } | |
| 662 | 662 | for key, value := range values { | |
| 663 | 663 | name, ok := registered[key] | |
| 664 | 664 | if !ok { | |
| 665 | - | return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing) | |
| 665 | + | return fmt.Errorf("apps: secret %q is not declared for service %q of app %q; add it under [services.%s.secrets] in the manifest and run `gordon apps apply`: %w", key, service, app, service, domain.ErrAppSecretMissing) | |
| 666 | 666 | } | |
| 667 | 667 | path, err := s.secretPath(ctx, app, service, name) | |
| 668 | 668 | if err != nil { | |
| @@ -680,13 +680,13 @@ func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key str | |||
| 680 | 680 | if service == "" || key == "" { | |
| 681 | 681 | return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec) | |
| 682 | 682 | } | |
| 683 | - | registered, err := s.secretNames(ctx, app, service) | |
| 683 | + | registered, err := s.secretNames(ctx, app, service, "") | |
| 684 | 684 | if err != nil { | |
| 685 | 685 | return err | |
| 686 | 686 | } | |
| 687 | 687 | name, ok := registered[key] | |
| 688 | 688 | if !ok { | |
| 689 | - | return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing) | |
| 689 | + | return fmt.Errorf("apps: secret %q is not declared for service %q of app %q: %w", key, service, app, domain.ErrAppSecretMissing) | |
| 690 | 690 | } | |
| 691 | 691 | // Refused while the name is still referenced by desired or active. | |
| 692 | 692 | if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil { | |
| @@ -711,37 +711,45 @@ func (s *AppServiceImpl) secretPath(ctx context.Context, app, service, name stri | |||
| 711 | 711 | return domain.AppSecretPathForID(ownership.ID, app, service, name), nil | |
| 712 | 712 | } | |
| 713 | 713 | ||
| 714 | - | // secretNames returns env-key → secret-name registrations. | |
| 715 | - | func (s *AppServiceImpl) secretNames(ctx context.Context, app, service string) (map[string]string, error) { | |
| 714 | + | // secretNames returns env-key → secret-name registrations. An app with no | |
| 715 | + | // applied state, or a service absent from it, fails; hint is appended to | |
| 716 | + | // that message so each caller can suggest its own next step. | |
| 717 | + | func (s *AppServiceImpl) secretNames(ctx context.Context, app, service, hint string) (map[string]string, error) { | |
| 716 | 718 | merged := map[string]string{} | |
| 717 | - | desired, ok, err := s.store.LoadDesired(ctx, app) | |
| 719 | + | found := false | |
| 720 | + | desired, hasDesired, err := s.store.LoadDesired(ctx, app) | |
| 718 | 721 | if err != nil { | |
| 719 | 722 | return nil, err | |
| 720 | 723 | } | |
| 721 | - | if ok { | |
| 724 | + | if hasDesired { | |
| 722 | 725 | for _, svc := range desired.Spec.Services { | |
| 723 | 726 | if svc.Name == service { | |
| 727 | + | found = true | |
| 724 | 728 | for envKey, name := range svc.Secrets { | |
| 725 | 729 | merged[envKey] = name | |
| 726 | 730 | } | |
| 727 | 731 | } | |
| 728 | 732 | } | |
| 729 | 733 | } | |
| 730 | - | active, ok, err := s.store.LoadActive(ctx, app) | |
| 734 | + | active, hasActive, err := s.store.LoadActive(ctx, app) | |
| 731 | 735 | if err != nil { | |
| 732 | 736 | return nil, err | |
| 733 | 737 | } | |
| 734 | - | if ok { | |
| 738 | + | if hasActive { | |
| 735 | 739 | for name, svc := range active.Services { | |
| 736 | 740 | if name == service { | |
| 741 | + | found = true | |
| 737 | 742 | for envKey, secretName := range svc.Spec.Secrets { | |
| 738 | 743 | merged[envKey] = secretName | |
| 739 | 744 | } | |
| 740 | 745 | } | |
| 741 | 746 | } | |
| 742 | 747 | } | |
| 743 | - | if len(merged) == 0 { | |
| 744 | - | return nil, fmt.Errorf("apps: service %q has no registered secrets: %w", service, domain.ErrAppSecretMissing) | |
| 748 | + | if !hasDesired && !hasActive { | |
| 749 | + | return nil, fmt.Errorf("apps: app %q has no applied manifest%s: %w", app, hint, domain.ErrAppSecretMissing) | |
| 750 | + | } | |
| 751 | + | if !found { | |
| 752 | + | return nil, fmt.Errorf("apps: service %q is not in the applied manifest of app %q%s: %w", service, app, hint, domain.ErrAppSecretMissing) | |
| 745 | 753 | } | |
| 746 | 754 | return merged, nil | |
| 747 | 755 | } | |
| @@ -655,14 +655,14 @@ func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, va | |||||
| 655 | if service == "" { | 655 | if service == "" { | ||
| 656 | return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec) | 656 | return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec) | ||
| 657 | } | 657 | } | ||
| 658 | - | registered, err := s.secretNames(ctx, app, service) | 658 | + | registered, err := s.secretNames(ctx, app, service, "; run `gordon apps apply` first") |
| 659 | if err != nil { | 659 | if err != nil { | ||
| 660 | return err | 660 | return err | ||
| 661 | } | 661 | } | ||
| 662 | for key, value := range values { | 662 | for key, value := range values { | ||
| 663 | name, ok := registered[key] | 663 | name, ok := registered[key] | ||
| 664 | if !ok { | 664 | if !ok { | ||
| 665 | - | return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing) | 665 | + | return fmt.Errorf("apps: secret %q is not declared for service %q of app %q; add it under [services.%s.secrets] in the manifest and run `gordon apps apply`: %w", key, service, app, service, domain.ErrAppSecretMissing) |
| 666 | } | 666 | } | ||
| 667 | path, err := s.secretPath(ctx, app, service, name) | 667 | path, err := s.secretPath(ctx, app, service, name) | ||
| 668 | if err != nil { | 668 | if err != nil { | ||
| @@ -680,13 +680,13 @@ func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key str | |||||
| 680 | if service == "" || key == "" { | 680 | if service == "" || key == "" { | ||
| 681 | return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec) | 681 | return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec) | ||
| 682 | } | 682 | } | ||
| 683 | - | registered, err := s.secretNames(ctx, app, service) | 683 | + | registered, err := s.secretNames(ctx, app, service, "") |
| 684 | if err != nil { | 684 | if err != nil { | ||
| 685 | return err | 685 | return err | ||
| 686 | } | 686 | } | ||
| 687 | name, ok := registered[key] | 687 | name, ok := registered[key] | ||
| 688 | if !ok { | 688 | if !ok { | ||
| 689 | - | return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing) | 689 | + | return fmt.Errorf("apps: secret %q is not declared for service %q of app %q: %w", key, service, app, domain.ErrAppSecretMissing) |
| 690 | } | 690 | } | ||
| 691 | // Refused while the name is still referenced by desired or active. | 691 | // Refused while the name is still referenced by desired or active. | ||
| 692 | if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil { | 692 | if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil { | ||
| @@ -711,37 +711,45 @@ func (s *AppServiceImpl) secretPath(ctx context.Context, app, service, name stri | |||||
| 711 | return domain.AppSecretPathForID(ownership.ID, app, service, name), nil | 711 | return domain.AppSecretPathForID(ownership.ID, app, service, name), nil | ||
| 712 | } | 712 | } | ||
| 713 | 713 | ||||
| 714 | - | // secretNames returns env-key → secret-name registrations. | 714 | + | // secretNames returns env-key → secret-name registrations. An app with no |
| 715 | - | func (s *AppServiceImpl) secretNames(ctx context.Context, app, service string) (map[string]string, error) { | 715 | + | // applied state, or a service absent from it, fails; hint is appended to |
| 716 | + | // that message so each caller can suggest its own next step. | |||
| 717 | + | func (s *AppServiceImpl) secretNames(ctx context.Context, app, service, hint string) (map[string]string, error) { | |||
| 716 | merged := map[string]string{} | 718 | merged := map[string]string{} | ||
| 717 | - | desired, ok, err := s.store.LoadDesired(ctx, app) | 719 | + | found := false |
| 720 | + | desired, hasDesired, err := s.store.LoadDesired(ctx, app) | |||
| 718 | if err != nil { | 721 | if err != nil { | ||
| 719 | return nil, err | 722 | return nil, err | ||
| 720 | } | 723 | } | ||
| 721 | - | if ok { | 724 | + | if hasDesired { |
| 722 | for _, svc := range desired.Spec.Services { | 725 | for _, svc := range desired.Spec.Services { | ||
| 723 | if svc.Name == service { | 726 | if svc.Name == service { | ||
| 727 | + | found = true | |||
| 724 | for envKey, name := range svc.Secrets { | 728 | for envKey, name := range svc.Secrets { | ||
| 725 | merged[envKey] = name | 729 | merged[envKey] = name | ||
| 726 | } | 730 | } | ||
| 727 | } | 731 | } | ||
| 728 | } | 732 | } | ||
| 729 | } | 733 | } | ||
| 730 | - | active, ok, err := s.store.LoadActive(ctx, app) | 734 | + | active, hasActive, err := s.store.LoadActive(ctx, app) |
| 731 | if err != nil { | 735 | if err != nil { | ||
| 732 | return nil, err | 736 | return nil, err | ||
| 733 | } | 737 | } | ||
| 734 | - | if ok { | 738 | + | if hasActive { |
| 735 | for name, svc := range active.Services { | 739 | for name, svc := range active.Services { | ||
| 736 | if name == service { | 740 | if name == service { | ||
| 741 | + | found = true | |||
| 737 | for envKey, secretName := range svc.Spec.Secrets { | 742 | for envKey, secretName := range svc.Spec.Secrets { | ||
| 738 | merged[envKey] = secretName | 743 | merged[envKey] = secretName | ||
| 739 | } | 744 | } | ||
| 740 | } | 745 | } | ||
| 741 | } | 746 | } | ||
| 742 | } | 747 | } | ||
| 743 | - | if len(merged) == 0 { | 748 | + | if !hasDesired && !hasActive { |
| 744 | - | return nil, fmt.Errorf("apps: service %q has no registered secrets: %w", service, domain.ErrAppSecretMissing) | 749 | + | return nil, fmt.Errorf("apps: app %q has no applied manifest%s: %w", app, hint, domain.ErrAppSecretMissing) |
| 750 | + | } | |||
| 751 | + | if !found { | |||
| 752 | + | return nil, fmt.Errorf("apps: service %q is not in the applied manifest of app %q%s: %w", service, app, hint, domain.ErrAppSecretMissing) | |||
| 745 | } | 753 | } | ||
| 746 | return merged, nil | 754 | return merged, nil | ||
| 747 | } | 755 | } | ||
M internal/usecase/apps/app_service_test.go +63 -0
| @@ -69,6 +69,69 @@ func TestAppServiceImpl_SecretsRoundTrip(t *testing.T) { | |||
| 69 | 69 | require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | func TestAppServiceImpl_SetSecrets_UnappliedServiceHint(t *testing.T) { | |
| 73 | + | ctx := context.Background() | |
| 74 | + | store := newMockAppState(t) | |
| 75 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |
| 76 | + | ||
| 77 | + | // App never applied: neither desired nor active exists. | |
| 78 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once() | |
| 79 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |
| 80 | + | err := svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"}) | |
| 81 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |
| 82 | + | assert.Contains(t, err.Error(), `app "blog" has no applied manifest; run `+"`gordon apps apply`"+` first`) | |
| 83 | + | ||
| 84 | + | // App applied, but this service is not in the applied spec. | |
| 85 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |
| 86 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once() | |
| 87 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |
| 88 | + | err = svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"}) | |
| 89 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |
| 90 | + | assert.Contains(t, err.Error(), `service "runner" is not in the applied manifest of app "blog"; run `+"`gordon apps apply`"+` first`) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestAppServiceImpl_DeleteSecret_NoApplyHint(t *testing.T) { | |
| 94 | + | ctx := context.Background() | |
| 95 | + | store := newMockAppState(t) | |
| 96 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |
| 97 | + | ||
| 98 | + | // Deleting never suggests applying or re-declaring the secret. | |
| 99 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |
| 100 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Twice() | |
| 101 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Twice() | |
| 102 | + | for _, tc := range []struct{ service, key string }{{"runner", "K"}, {"web", "NOPE"}} { | |
| 103 | + | err := svc.DeleteSecret(ctx, "blog", tc.service, tc.key) | |
| 104 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |
| 105 | + | assert.NotContains(t, err.Error(), "apply") | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestAppServiceImpl_SetSecrets_UndeclaredSecretHint(t *testing.T) { | |
| 110 | + | ctx := context.Background() | |
| 111 | + | store := newMockAppState(t) | |
| 112 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |
| 113 | + | ||
| 114 | + | // Service applied but declares no secrets. | |
| 115 | + | spec := secretSpec() | |
| 116 | + | spec.Services[0].Secrets = nil | |
| 117 | + | noSecrets := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: spec} | |
| 118 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(noSecrets, true, nil).Once() | |
| 119 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |
| 120 | + | err := svc.SetSecrets(ctx, "blog", "web", map[string]string{"K": "v"}) | |
| 121 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |
| 122 | + | assert.Contains(t, err.Error(), "[services.web.secrets]") | |
| 123 | + | assert.NotContains(t, err.Error(), "not in the applied manifest") | |
| 124 | + | ||
| 125 | + | // Service declares other secrets but not this key. | |
| 126 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |
| 127 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once() | |
| 128 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |
| 129 | + | err = svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}) | |
| 130 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |
| 131 | + | assert.Contains(t, err.Error(), `secret "NOPE" is not declared`) | |
| 132 | + | assert.Contains(t, err.Error(), "[services.web.secrets]") | |
| 133 | + | } | |
| 134 | + | ||
| 72 | 135 | func TestAppServiceImpl_OperationByKey(t *testing.T) { | |
| 73 | 136 | ctx := context.Background() | |
| 74 | 137 | store := newMockAppState(t) | |
| @@ -69,6 +69,69 @@ func TestAppServiceImpl_SecretsRoundTrip(t *testing.T) { | |||||
| 69 | require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing) | 69 | require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing) | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | func TestAppServiceImpl_SetSecrets_UnappliedServiceHint(t *testing.T) { | |||
| 73 | + | ctx := context.Background() | |||
| 74 | + | store := newMockAppState(t) | |||
| 75 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |||
| 76 | + | ||||
| 77 | + | // App never applied: neither desired nor active exists. | |||
| 78 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once() | |||
| 79 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |||
| 80 | + | err := svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"}) | |||
| 81 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 82 | + | assert.Contains(t, err.Error(), `app "blog" has no applied manifest; run `+"`gordon apps apply`"+` first`) | |||
| 83 | + | ||||
| 84 | + | // App applied, but this service is not in the applied spec. | |||
| 85 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |||
| 86 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once() | |||
| 87 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |||
| 88 | + | err = svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"}) | |||
| 89 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 90 | + | assert.Contains(t, err.Error(), `service "runner" is not in the applied manifest of app "blog"; run `+"`gordon apps apply`"+` first`) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestAppServiceImpl_DeleteSecret_NoApplyHint(t *testing.T) { | |||
| 94 | + | ctx := context.Background() | |||
| 95 | + | store := newMockAppState(t) | |||
| 96 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |||
| 97 | + | ||||
| 98 | + | // Deleting never suggests applying or re-declaring the secret. | |||
| 99 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |||
| 100 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Twice() | |||
| 101 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Twice() | |||
| 102 | + | for _, tc := range []struct{ service, key string }{{"runner", "K"}, {"web", "NOPE"}} { | |||
| 103 | + | err := svc.DeleteSecret(ctx, "blog", tc.service, tc.key) | |||
| 104 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 105 | + | assert.NotContains(t, err.Error(), "apply") | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestAppServiceImpl_SetSecrets_UndeclaredSecretHint(t *testing.T) { | |||
| 110 | + | ctx := context.Background() | |||
| 111 | + | store := newMockAppState(t) | |||
| 112 | + | svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default()) | |||
| 113 | + | ||||
| 114 | + | // Service applied but declares no secrets. | |||
| 115 | + | spec := secretSpec() | |||
| 116 | + | spec.Services[0].Secrets = nil | |||
| 117 | + | noSecrets := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: spec} | |||
| 118 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(noSecrets, true, nil).Once() | |||
| 119 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |||
| 120 | + | err := svc.SetSecrets(ctx, "blog", "web", map[string]string{"K": "v"}) | |||
| 121 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 122 | + | assert.Contains(t, err.Error(), "[services.web.secrets]") | |||
| 123 | + | assert.NotContains(t, err.Error(), "not in the applied manifest") | |||
| 124 | + | ||||
| 125 | + | // Service declares other secrets but not this key. | |||
| 126 | + | desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()} | |||
| 127 | + | store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once() | |||
| 128 | + | store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once() | |||
| 129 | + | err = svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}) | |||
| 130 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 131 | + | assert.Contains(t, err.Error(), `secret "NOPE" is not declared`) | |||
| 132 | + | assert.Contains(t, err.Error(), "[services.web.secrets]") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 72 | func TestAppServiceImpl_OperationByKey(t *testing.T) { | 135 | func TestAppServiceImpl_OperationByKey(t *testing.T) { | ||
| 73 | ctx := context.Background() | 136 | ctx := context.Background() | ||
| 74 | store := newMockAppState(t) | 137 | store := newMockAppState(t) | ||