detached 3a7fcc7

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:
211211 `--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments.
212212 An empty value is rejected, so `KEY=` is invalid.
213213
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+
214217 Running containers keep the values they were created with. Apply new values
215218 with `gordon apps deploy APP --service SVC`: deploy sees the changed secret and
216219 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 values217 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 and218 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
468468 return
469469 }
470470 if err := svc.SetSecrets(ctx, app, req.Service, req.Secrets); err != nil {
471- h.sendAppOpError(w, err)
471+ h.sendAppSecretWriteError(w, err)
472472 return
473473 }
474474 h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
@@ -492,12 +492,23 @@ func (h *Handler) handleAppSecretsDelete(w http.ResponseWriter, r *http.Request,
492492 return
493493 }
494494 if err := svc.DeleteSecret(ctx, app, req.Service, req.Key); err != nil {
495- h.sendAppOpError(w, err)
495+ h.sendAppSecretWriteError(w, err)
496496 return
497497 }
498498 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
499499 }
500500
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+
501512 // sendAppError writes the v3 error envelope (never carries logs).
502513 func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, cause, hint string) {
503514 w.Header().Set("Content-Type", "application/json")
@@ -468,7 +468,7 @@ func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, ap
468 return468 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 return472 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 return492 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 return496 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) {
441441 assert.Equal(t, http.StatusOK, rec.Code)
442442 }
443443
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+
444457 // TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle
445458 // mutation of a name with no app identity is a 404, not a 500 or a silent
446459 // 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 lifecycle457 // TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle
445 // mutation of a name with no app identity is a 404, not a 500 or a silent458 // 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
655655 if service == "" {
656656 return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec)
657657 }
658- registered, err := s.secretNames(ctx, app, service)
658+ registered, err := s.secretNames(ctx, app, service, "; run `gordon apps apply` first")
659659 if err != nil {
660660 return err
661661 }
662662 for key, value := range values {
663663 name, ok := registered[key]
664664 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)
666666 }
667667 path, err := s.secretPath(ctx, app, service, name)
668668 if err != nil {
@@ -680,13 +680,13 @@ func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key str
680680 if service == "" || key == "" {
681681 return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec)
682682 }
683- registered, err := s.secretNames(ctx, app, service)
683+ registered, err := s.secretNames(ctx, app, service, "")
684684 if err != nil {
685685 return err
686686 }
687687 name, ok := registered[key]
688688 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)
690690 }
691691 // Refused while the name is still referenced by desired or active.
692692 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
711711 return domain.AppSecretPathForID(ownership.ID, app, service, name), nil
712712 }
713713
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) {
716718 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)
718721 if err != nil {
719722 return nil, err
720723 }
721- if ok {
724+ if hasDesired {
722725 for _, svc := range desired.Spec.Services {
723726 if svc.Name == service {
727+ found = true
724728 for envKey, name := range svc.Secrets {
725729 merged[envKey] = name
726730 }
727731 }
728732 }
729733 }
730- active, ok, err := s.store.LoadActive(ctx, app)
734+ active, hasActive, err := s.store.LoadActive(ctx, app)
731735 if err != nil {
732736 return nil, err
733737 }
734- if ok {
738+ if hasActive {
735739 for name, svc := range active.Services {
736740 if name == service {
741+ found = true
737742 for envKey, secretName := range svc.Spec.Secrets {
738743 merged[envKey] = secretName
739744 }
740745 }
741746 }
742747 }
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)
745753 }
746754 return merged, nil
747755 }
@@ -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 err660 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 err685 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), nil711 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, err722 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] = name729 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, err736 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] = secretName743 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, nil754 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) {
6969 require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing)
7070 }
7171
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+
72135 func TestAppServiceImpl_OperationByKey(t *testing.T) {
73136 ctx := context.Background()
74137 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close