1package apps
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sort"
8 "strings"
9 "sync"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/boundaries/in"
14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/deployment"
17)
18
19// AppServiceImpl implements in.AppService over the deployment engine. The
20// daemon owns this implementation; CLI always reaches it via the daemon.
21// All mutations run recovery-before-mutation through the engine.
22type AppServiceImpl struct {
23 store out.AppCatalogReader
24 deploy deployEngine
25 secrets out.SecretWriter
26 log zerowrap.Logger
27 // core is the single configured apps service. It is built once and
28 // reconfigured in place, never reconstructed per request.
29 core *Service
30
31 // lifecycleMu guards the daemon lifecycle. daemonCtx is the
32 // daemon-owned parent for background executions: it is never a request
33 // context, so request cancellation cannot abort an in-flight deploy.
34 // stopped gates new executions once Shutdown runs, handoffs tracks the
35 // Deploy request hand-offs (claim through scheduling or settling) so
36 // Shutdown never closes state under a claim it did not wait for, and
37 // executions tracks the in-flight background executions so Shutdown can
38 // wait for them.
39 lifecycleMu sync.Mutex
40 daemonCtx context.Context
41 daemonStop context.CancelFunc
42 stopped bool
43 handoffs sync.WaitGroup
44 executions sync.WaitGroup
45}
46
47// deployEngine is the subset of the deployment engine the app service needs.
48type deployEngine interface {
49 StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error)
50 AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error
51 ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error)
52 Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
53 Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
54 Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)
55 Remove(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
56}
57
58// AppStore is the state the app administration service needs: the
59// read model plus the apply path.
60type AppStore interface {
61 out.AppCatalogReader
62 out.AppApplyStore
63}
64
65// NewAppServiceImpl wires the driving-port implementation.
66func NewAppServiceImpl(store AppStore, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl {
67 daemonCtx, daemonStop := context.WithCancel(context.Background())
68 return &AppServiceImpl{
69 store: store,
70 deploy: deploy,
71 secrets: secrets,
72 log: log,
73 core: NewService(store, log),
74 daemonCtx: daemonCtx,
75 daemonStop: daemonStop,
76 }
77}
78
79// WithDaemonContext sets the parent context that owns background deploy
80// executions. It must be called during wiring, before the first Deploy. The
81// derived context is cancelled by Shutdown; request contexts are never used
82// as its parent.
83func (s *AppServiceImpl) WithDaemonContext(parent context.Context) *AppServiceImpl {
84 if parent == nil {
85 parent = context.Background()
86 }
87 s.lifecycleMu.Lock()
88 defer s.lifecycleMu.Unlock()
89 if s.stopped {
90 return s
91 }
92 ctx, cancel := context.WithCancel(parent)
93 if s.daemonStop != nil {
94 s.daemonStop()
95 }
96 s.daemonCtx, s.daemonStop = ctx, cancel
97 return s
98}
99
100// Shutdown stops scheduling background deploy executions, cancels the daemon
101// context so in-flight executions abort with a recoverable non-terminal
102// journal, and waits for the in-flight hand-offs and executions to unwind or
103// for ctx to expire. Waiting for the hand-offs first means a Deploy that
104// claimed an operation just before Shutdown always schedules or settles it
105// before Shutdown returns.
106func (s *AppServiceImpl) Shutdown(ctx context.Context) error {
107 s.lifecycleMu.Lock()
108 if !s.stopped {
109 s.stopped = true
110 s.daemonStop()
111 }
112 s.lifecycleMu.Unlock()
113
114 done := make(chan struct{})
115 go func() {
116 s.handoffs.Wait()
117 s.executions.Wait()
118 close(done)
119 }()
120 select {
121 case <-done:
122 return nil
123 case <-ctx.Done():
124 return ctx.Err()
125 }
126}
127
128// scheduleExecution runs the claimed operation's effects on the daemon
129// context. Only the owner of a freshly claimed key reaches it, so a duplicate
130// key can never spawn a second execution. The journal claimed by StartDeploy
131// is already durable when the goroutine starts. When Shutdown won the race
132// between the claim and scheduling, the claim is settled without starting:
133// the live marker is released and the journal converged as interrupted, so it
134// can neither execute nor stay marked live forever.
135func (s *AppServiceImpl) scheduleExecution(claim deployment.DeployClaim) error {
136 opFields := map[string]any{
137 zerowrap.FieldLayer: "usecase",
138 zerowrap.FieldUseCase: "Deploy",
139 "app": claim.App,
140 "op": claim.Op,
141 }
142 s.lifecycleMu.Lock()
143 if s.stopped {
144 settleCtx := zerowrap.CtxWithFields(context.WithoutCancel(s.daemonCtx), opFields)
145 s.lifecycleMu.Unlock()
146 settleLog := zerowrap.FromCtx(settleCtx)
147 settleLog.Warn().Msg("apps: deploy claimed during shutdown; settling the claim without executing")
148 if err := s.deploy.AbandonDeploy(settleCtx, claim); err != nil {
149 settleLog.Warn().Err(err).Msg("apps: failed to settle a deploy claimed during shutdown")
150 }
151 return fmt.Errorf("apps: deploy claimed during shutdown: %w", domain.ErrAppStateConflict)
152 }
153 ctx := zerowrap.CtxWithFields(s.daemonCtx, opFields)
154 s.executions.Add(1)
155 s.lifecycleMu.Unlock()
156
157 go func() {
158 defer s.executions.Done()
159 execLog := zerowrap.FromCtx(ctx)
160 if _, err := s.deploy.ExecuteDeploy(ctx, claim); err != nil {
161 execLog.Warn().Err(err).Msg("apps: background deploy execution failed")
162 }
163 }()
164 return nil
165}
166
167// WithEntrypoints supplies the installation entrypoint listeners used to
168// validate L4 publish declarations at apply time.
169func (s *AppServiceImpl) WithEntrypoints(listeners map[string]domain.EntryPointListener) *AppServiceImpl {
170 s.core.WithEntrypoints(listeners)
171 return s
172}
173
174// WithGCBarrier supplies the process-wide GC barrier used to serialize an
175// apply against destructive prune.
176func (s *AppServiceImpl) WithGCBarrier(barrier out.GCBarrier) *AppServiceImpl {
177 s.core.WithGCBarrier(barrier)
178 return s
179}
180
181// WithImagePolicy supplies the registry policy used for manifest validation.
182func (s *AppServiceImpl) WithImagePolicy(policy domain.ImageSourcePolicy) *AppServiceImpl {
183 s.core.WithImagePolicy(policy)
184 return s
185}
186
187// WithBindPolicies supplies the administrative bind policies used at apply time.
188func (s *AppServiceImpl) WithBindPolicies(policies map[string]domain.AppBindPolicy) *AppServiceImpl {
189 s.core.SetBindPolicies(policies)
190 return s
191}
192
193// SetBindPolicies atomically replaces the administrative bind policies. It is
194// safe to call from a config reload while applies are in flight.
195func (s *AppServiceImpl) SetBindPolicies(policies map[string]domain.AppBindPolicy) {
196 s.core.SetBindPolicies(policies)
197}
198
199// WithDevicePolicies supplies the administrative device policies used at apply time.
200func (s *AppServiceImpl) WithDevicePolicies(policies map[string]domain.AppDevicePolicy) *AppServiceImpl {
201 s.core.SetDevicePolicies(policies)
202 return s
203}
204
205// SetDevicePolicies atomically replaces the administrative device policies. It is
206// safe to call from a config reload while applies are in flight.
207func (s *AppServiceImpl) SetDevicePolicies(policies map[string]domain.AppDevicePolicy) {
208 s.core.SetDevicePolicies(policies)
209}
210
211// WithCapabilityPolicies supplies the administrative capability policies used at apply time.
212func (s *AppServiceImpl) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *AppServiceImpl {
213 s.core.SetCapabilityPolicies(policies)
214 return s
215}
216
217// SetCapabilityPolicies atomically replaces the administrative capability
218// policies. It is safe to call from a config reload while applies are in flight.
219func (s *AppServiceImpl) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
220 s.core.SetCapabilityPolicies(policies)
221}
222
223var _ in.AppService = (*AppServiceImpl)(nil)
224
225// Apply implements in.AppService.
226func (s *AppServiceImpl) Apply(ctx context.Context, spec domain.AppSpec, source []byte, dryRun bool) (*in.AppApplyResult, *in.AppDryRunResult, error) {
227 applyResult, dryResult, err := s.core.Apply(ctx, spec, source, dryRun)
228 if err != nil {
229 return nil, nil, err
230 }
231 if dryResult != nil {
232 return nil, &in.AppDryRunResult{
233 App: spec.Name,
234 Valid: true,
235 Diff: dryResult.Diff,
236 }, nil
237 }
238 if applyResult == nil {
239 return nil, &in.AppDryRunResult{App: spec.Name, Valid: true}, nil
240 }
241 return &in.AppApplyResult{
242 App: applyResult.App,
243 FormerRevision: applyResult.FormerRevision,
244 ResultingRevision: applyResult.ResultingRevision,
245 Noop: applyResult.Noop,
246 Pending: applyResult.Pending,
247 Diff: applyResult.Diff,
248 IntentID: applyResult.IntentID,
249 }, nil, nil
250}
251
252// List implements in.AppService. Names whose only remaining record is a
253// retired operation journal are not apps any more and are not listed.
254func (s *AppServiceImpl) List(ctx context.Context) ([]in.AppSummary, error) {
255 apps, err := s.store.ListApps(ctx)
256 if err != nil {
257 return nil, err
258 }
259 sort.Strings(apps)
260 summaries := make([]in.AppSummary, 0, len(apps))
261 for _, app := range apps {
262 live, err := s.store.AppExists(ctx, app)
263 if err != nil {
264 return nil, err
265 }
266 if !live {
267 continue
268 }
269 summary, err := s.summary(ctx, app)
270 if err != nil {
271 return nil, err
272 }
273 summaries = append(summaries, summary)
274 }
275 return summaries, nil
276}
277
278// summary builds one list row from desired + ACTIVE + intent + the latest
279// journal outcome.
280func (s *AppServiceImpl) summary(ctx context.Context, app string) (in.AppSummary, error) {
281 desired, _, err := s.store.LoadDesired(ctx, app)
282 if err != nil {
283 return in.AppSummary{}, err
284 }
285 active, ok, err := s.store.LoadActive(ctx, app)
286 if err != nil {
287 return in.AppSummary{}, err
288 }
289 intent, err := s.store.LoadIntent(ctx, app)
290 if err != nil {
291 return in.AppSummary{}, err
292 }
293 summary := in.AppSummary{
294 App: app,
295 Desired: desired.Revision,
296 DesiredStatus: desired.Status,
297 Pending: desiredPending(desired.Revision, active, ok),
298 Stopped: intent.Stopped,
299 }
300 if ok {
301 summary.Active = active.ConvergedRevision
302 summary.Converged = active.Converged
303 }
304 latest, hasOp, err := s.store.LoadLatestOperation(ctx, app)
305 if err != nil {
306 return in.AppSummary{}, err
307 }
308 if hasOp {
309 summary.LastOutcome = latest.Outcome
310 }
311 return summary, nil
312}
313
314// Show implements in.AppService.
315func (s *AppServiceImpl) Show(ctx context.Context, app string) (*in.AppDetail, error) {
316 live, err := s.store.AppExists(ctx, app)
317 if err != nil {
318 return nil, err
319 }
320 if !live {
321 return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
322 }
323 desired, _, err := s.store.LoadDesired(ctx, app)
324 if err != nil {
325 return nil, err
326 }
327 active, ok, err := s.store.LoadActive(ctx, app)
328 if err != nil {
329 return nil, err
330 }
331 intent, err := s.store.LoadIntent(ctx, app)
332 if err != nil {
333 return nil, err
334 }
335 detail := &in.AppDetail{
336 App: app,
337 DesiredRevision: desired.Revision,
338 DesiredStatus: desired.Status,
339 Pending: desiredPending(desired.Revision, active, ok),
340 Converged: active.Converged,
341 ConvergedRevision: active.ConvergedRevision,
342 Services: map[string]in.AppServiceView{},
343 Stopped: intent.Stopped,
344 }
345 ownership, err := s.store.LoadOwnership(ctx, app)
346 if err != nil {
347 return nil, err
348 }
349 detail.Retained = retainedView(ownership)
350 for name, svc := range active.Services {
351 detail.Services[name] = in.AppServiceView{
352 EffectiveRevision: svc.EffectiveRevision,
353 Digest: svc.Digest,
354 Container: svc.Container,
355 RestartUnsafe: ownership.Services[name].RestartUnsafe,
356 }
357 }
358 latest, ok, err := s.store.LoadLatestOperation(ctx, app)
359 if err != nil {
360 return nil, err
361 }
362 if ok {
363 detail.LastOp = latest.Op
364 detail.LastOpKind = latest.Kind
365 detail.LastOutcome = latest.Outcome
366 detail.LastOpStartedAt = latest.StartedAt
367 }
368 return detail, nil
369}
370
371// desiredPending reports desired state the active revision has not
372// reached. It reads desired vs ACTIVE only: a journal outcome never
373// decides convergence.
374func desiredPending(desiredRevision string, active domain.AppActive, hasActive bool) bool {
375 if desiredRevision == "" {
376 return false
377 }
378 return !hasActive || !active.Converged || active.ConvergedRevision != desiredRevision
379}
380
381// retainedView lists the resources the app owns and would retain on
382// removal, sorted for stable output.
383func retainedView(ownership domain.AppOwnership) in.AppRetainedView {
384 view := in.AppRetainedView{}
385 for _, volume := range ownership.Volumes {
386 name := volume.RuntimeName
387 if name == "" {
388 name = volume.Name
389 }
390 view.Volumes = append(view.Volumes, name)
391 }
392 for _, secret := range ownership.Secrets {
393 view.Secrets = append(view.Secrets, secret.Path)
394 }
395 for _, image := range ownership.Images {
396 view.Images = append(view.Images, image.Reference)
397 }
398 sort.Strings(view.Volumes)
399 sort.Strings(view.Secrets)
400 sort.Strings(view.Images)
401 return view
402}
403
404// Diff implements in.AppService.
405func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff, error) {
406 desired, ok, err := s.store.LoadDesired(ctx, app)
407 if err != nil {
408 return domain.AppDiff{}, err
409 }
410 if !ok {
411 return domain.AppDiff{}, fmt.Errorf("apps: app %q has no desired state: %w", app, domain.ErrAppRevisionNotFound)
412 }
413 active, _, err := s.store.LoadActive(ctx, app)
414 if err != nil {
415 return domain.AppDiff{}, err
416 }
417 effective, err := activeSpec(ctx, s.store, active, desired.Spec.Env)
418 if err != nil {
419 return domain.AppDiff{}, err
420 }
421 return domain.DiffAppSpec(desired.Spec, effective), nil
422}
423
424// Deploy implements in.AppService. It enrolls the request hand-off with the
425// lifecycle first, so a deploy arriving after shutdown began is refused before
426// any claim, and one already in flight is always scheduled or settled before
427// Shutdown returns. StartDeploy then claims and persists the operation under
428// the app lock, and the effects run in the background on the daemon context,
429// so Deploy returns the still-running journal promptly and request
430// cancellation cannot abort an in-flight replacement. A repeated idempotency
431// key is never owned twice: its stored journal replays instead of spawning a
432// second execution, and only the owner schedules any work.
433func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
434 if err := s.requireServiceScope(ctx, app, revision, service, all, true); err != nil {
435 return nil, err
436 }
437 done, err := s.beginDeploy()
438 if err != nil {
439 return nil, err
440 }
441 defer done()
442
443 started, err := s.deploy.StartDeploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey})
444 if err != nil {
445 return nil, err
446 }
447 if !started.Owned {
448 // The key already answered this request: its stored journal is the
449 // result and no workload is touched again.
450 return s.operationResult(ctx, app, started.Claim.Op, started.ReplayError())
451 }
452 if err := s.scheduleExecution(started.Claim); err != nil {
453 return s.operationResult(ctx, app, started.Claim.Op, err)
454 }
455 return s.operationResult(ctx, app, started.Claim.Op, nil)
456}
457
458// requireServiceScope refuses an app-wide deploy or restart of a
459// multi-service app unless the caller asked for it with all, so an
460// unrelated service (a database, say) is never replaced by accident. It
461// runs before any claim or idempotency key is recorded. Deploy counts the
462// selected revision — an explicit revision when one is given, the desired
463// head otherwise — plus ACTIVE, so an older multi-service revision cannot
464// slip through a single-service head, and a first deploy is covered.
465// Restart counts ACTIVE only.
466func (s *AppServiceImpl) requireServiceScope(ctx context.Context, app, revision, service string, all, includeDesired bool) error {
467 if all && service != "" {
468 return fmt.Errorf("apps: --all and --service are mutually exclusive: %w", domain.ErrAppServiceScope)
469 }
470 if all || service != "" {
471 return nil
472 }
473 names := map[string]struct{}{}
474 if includeDesired {
475 desired, err := s.loadScopeRevision(ctx, app, revision)
476 if err != nil {
477 return err
478 }
479 for _, svc := range desired.Spec.Services {
480 names[svc.Name] = struct{}{}
481 }
482 }
483 active, ok, err := s.store.LoadActive(ctx, app)
484 if err != nil {
485 return fmt.Errorf("apps: load active of app %q for service scope: %w", app, err)
486 }
487 if ok {
488 for name := range active.Services {
489 names[name] = struct{}{}
490 }
491 }
492 if len(names) <= 1 {
493 return nil
494 }
495 sorted := make([]string, 0, len(names))
496 for name := range names {
497 sorted = append(sorted, name)
498 }
499 sort.Strings(sorted)
500 return fmt.Errorf("app %s has several services (%s): pass --service NAME or --all: %w",
501 app, strings.Join(sorted, ", "), domain.ErrAppServiceScope)
502}
503
504// loadScopeRevision returns the revision a deploy would activate: the
505// explicit revision when one is selected, the desired head otherwise. A
506// missing revision counts no services: an unknown explicit revision is
507// left for StartDeploy to report, and a missing desired head means a
508// first deploy with nothing captured yet.
509func (s *AppServiceImpl) loadScopeRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error) {
510 if revision != "" {
511 rev, err := s.store.LoadRevision(ctx, app, revision)
512 if err != nil {
513 if errors.Is(err, domain.ErrAppRevisionNotFound) {
514 return domain.AppDesiredRevision{}, nil
515 }
516 return domain.AppDesiredRevision{}, fmt.Errorf("apps: load revision %q of app %q for service scope: %w", revision, app, err)
517 }
518 return rev, nil
519 }
520 desired, ok, err := s.store.LoadDesired(ctx, app)
521 if err != nil {
522 return domain.AppDesiredRevision{}, fmt.Errorf("apps: load desired of app %q for service scope: %w", app, err)
523 }
524 if !ok {
525 return domain.AppDesiredRevision{}, nil
526 }
527 return desired, nil
528}
529
530// beginDeploy enrolls one Deploy hand-off before the engine claims anything.
531// It refuses the request once Shutdown began, and otherwise guarantees Shutdown
532// waits for the hand-off (claim, schedule, or settle) to finish before state is
533// torn down, so no claim is made or settled against closed state. The returned
534// done func must be called exactly once.
535func (s *AppServiceImpl) beginDeploy() (func(), error) {
536 s.lifecycleMu.Lock()
537 defer s.lifecycleMu.Unlock()
538 if s.stopped {
539 return nil, fmt.Errorf("apps: deploy refused: daemon is shutting down: %w", domain.ErrAppStateConflict)
540 }
541 s.handoffs.Add(1)
542 return s.handoffs.Done, nil
543}
544
545// Stop implements in.AppService.
546func (s *AppServiceImpl) Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
547 result, err := s.deploy.Stop(ctx, app, idempotencyKey)
548 if result == nil {
549 return nil, err
550 }
551 return s.operationResult(ctx, app, result.Op, err)
552}
553
554// Start implements in.AppService.
555func (s *AppServiceImpl) Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
556 result, err := s.deploy.Start(ctx, app, idempotencyKey)
557 if result == nil {
558 return nil, err
559 }
560 return s.operationResult(ctx, app, result.Op, err)
561}
562
563// Restart implements in.AppService.
564func (s *AppServiceImpl) Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
565 if err := s.requireServiceScope(ctx, app, "", service, all, false); err != nil {
566 return nil, err
567 }
568 result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)
569 if result == nil {
570 return nil, err
571 }
572 return s.operationResult(ctx, app, result.Op, err)
573}
574
575// Remove implements in.AppService.
576func (s *AppServiceImpl) Remove(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
577 result, err := s.deploy.Remove(ctx, app, idempotencyKey)
578 if result == nil {
579 return nil, err
580 }
581 return s.operationResult(ctx, app, result.Op, err)
582}
583
584// operationResult loads the journal an engine call produced. A call that
585// failed after its journal was claimed returns both, so callers can
586// inspect the recorded outcome instead of losing it.
587func (s *AppServiceImpl) operationResult(ctx context.Context, app, opID string, callErr error) (*domain.AppOperation, error) {
588 if opID == "" {
589 return nil, callErr
590 }
591 op, err := s.store.LoadOperation(ctx, app, opID)
592 if err != nil {
593 if callErr != nil {
594 return nil, callErr
595 }
596 return nil, err
597 }
598 if callErr != nil {
599 return &op, callErr
600 }
601 return &op, nil
602}
603
604// OperationByKey implements in.AppService.
605func (s *AppServiceImpl) OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error) {
606 op, err := s.store.LoadOperation(ctx, app, key)
607 if err != nil {
608 return nil, err
609 }
610 return &op, nil
611}
612
613// ListSecrets implements in.AppService without reading secret values. A
614// name with no live identity is not found; ownership-only apps remain live
615// and report their registrations (possibly none).
616func (s *AppServiceImpl) ListSecrets(ctx context.Context, app, service string) ([]in.AppSecretMetadata, error) {
617 live, err := s.store.AppExists(ctx, app)
618 if err != nil {
619 return nil, fmt.Errorf("list app secrets: check app %q: %w", app, err)
620 }
621 if !live {
622 return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
623 }
624 entries := []in.AppSecretMetadata{}
625 desired, ok, err := s.store.LoadDesired(ctx, app)
626 if err != nil {
627 return nil, fmt.Errorf("list app secrets: load desired for %q: %w", app, err)
628 }
629 if ok {
630 for _, svc := range desired.Spec.Services {
631 entries = appendSecretMetadata(entries, service, svc.Name, svc.Secrets, "desired")
632 }
633 }
634 active, ok, err := s.store.LoadActive(ctx, app)
635 if err != nil {
636 return nil, fmt.Errorf("list app secrets: load active for %q: %w", app, err)
637 }
638 if ok {
639 for svcName, svc := range active.Services {
640 entries = appendSecretMetadata(entries, service, svcName, svc.Spec.Secrets, "active")
641 }
642 }
643 sort.Slice(entries, func(i, j int) bool {
644 a, b := entries[i], entries[j]
645 if a.Service != b.Service {
646 return a.Service < b.Service
647 }
648 if a.Key != b.Key {
649 return a.Key < b.Key
650 }
651 if a.Source != b.Source {
652 return a.Source < b.Source
653 }
654 return a.Name < b.Name
655 })
656 return entries, nil
657}
658
659func appendSecretMetadata(entries []in.AppSecretMetadata, filter, service string, secrets map[string]string, source string) []in.AppSecretMetadata {
660 if filter != "" && filter != service {
661 return entries
662 }
663 for key, name := range secrets {
664 entries = append(entries, in.AppSecretMetadata{Service: service, Key: key, Name: name, Source: source, Presence: "unknown"})
665 }
666 return entries
667}
668
669// SetSecrets implements in.AppService.
670func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {
671 if service == "" {
672 return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec)
673 }
674 registered, err := s.secretNames(ctx, app, service, "; run `gordon apps apply` first")
675 if err != nil {
676 return err
677 }
678 for key, value := range values {
679 name, ok := registered[key]
680 if !ok {
681 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)
682 }
683 path, err := s.secretPath(ctx, app, service, name)
684 if err != nil {
685 return err
686 }
687 if err := s.secrets.SetSecret(ctx, path, value); err != nil {
688 return fmt.Errorf("apps: write secret %q: %w", key, err)
689 }
690 }
691 return nil
692}
693
694// DeleteSecret implements in.AppService.
695func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key string) error {
696 if service == "" || key == "" {
697 return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec)
698 }
699 registered, err := s.secretNames(ctx, app, service, "")
700 if err != nil {
701 return err
702 }
703 name, ok := registered[key]
704 if !ok {
705 return fmt.Errorf("apps: secret %q is not declared for service %q of app %q: %w", key, service, app, domain.ErrAppSecretMissing)
706 }
707 // Refused while the name is still referenced by desired or active.
708 if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil {
709 return err
710 } else if referenced {
711 return fmt.Errorf("apps: secret %q still referenced: %w", key, domain.ErrAppStateConflict)
712 }
713 path, err := s.secretPath(ctx, app, service, name)
714 if err != nil {
715 return err
716 }
717 return s.secrets.DeleteSecret(ctx, path)
718}
719
720// secretPath returns the UUID-keyed pass path for one secret name.
721// Empty ID means legacy name-keyed paths (records predating UUIDs).
722func (s *AppServiceImpl) secretPath(ctx context.Context, app, service, name string) (string, error) {
723 ownership, err := s.store.LoadOwnership(ctx, app)
724 if err != nil {
725 return "", err
726 }
727 return domain.AppSecretPathForID(ownership.ID, app, service, name), nil
728}
729
730// secretNames returns env-key → secret-name registrations. An app with no
731// applied state, or a service absent from it, fails; hint is appended to
732// that message so each caller can suggest its own next step.
733func (s *AppServiceImpl) secretNames(ctx context.Context, app, service, hint string) (map[string]string, error) {
734 merged := map[string]string{}
735 found := false
736 desired, hasDesired, err := s.store.LoadDesired(ctx, app)
737 if err != nil {
738 return nil, err
739 }
740 if hasDesired {
741 for _, svc := range desired.Spec.Services {
742 if svc.Name == service {
743 found = true
744 for envKey, name := range svc.Secrets {
745 merged[envKey] = name
746 }
747 }
748 }
749 }
750 active, hasActive, err := s.store.LoadActive(ctx, app)
751 if err != nil {
752 return nil, err
753 }
754 if hasActive {
755 for name, svc := range active.Services {
756 if name == service {
757 found = true
758 for envKey, secretName := range svc.Spec.Secrets {
759 merged[envKey] = secretName
760 }
761 }
762 }
763 }
764 if !hasDesired && !hasActive {
765 return nil, fmt.Errorf("apps: app %q has no applied manifest%s: %w", app, hint, domain.ErrAppSecretMissing)
766 }
767 if !found {
768 return nil, fmt.Errorf("apps: service %q is not in the applied manifest of app %q%s: %w", service, app, hint, domain.ErrAppSecretMissing)
769 }
770 return merged, nil
771}
772
773// secretReferenced reports desired/active references to a secret name.
774func (s *AppServiceImpl) secretReferenced(ctx context.Context, app, service, name string) (bool, error) {
775 desired, ok, err := s.store.LoadDesired(ctx, app)
776 if err != nil {
777 return false, err
778 }
779 if ok {
780 for _, svc := range desired.Spec.Services {
781 if svc.Name != service {
782 continue
783 }
784 for _, secretName := range svc.Secrets {
785 if secretName == name {
786 return true, nil
787 }
788 }
789 }
790 }
791 active, ok, err := s.store.LoadActive(ctx, app)
792 if err != nil {
793 return false, err
794 }
795 if ok {
796 for svcName, svc := range active.Services {
797 if svcName != service {
798 continue
799 }
800 for _, secretName := range svc.Spec.Secrets {
801 if secretName == name {
802 return true, nil
803 }
804 }
805 }
806 }
807 return false, nil
808}