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
211var _ in.AppService = (*AppServiceImpl)(nil)
212
213// Apply implements in.AppService.
214func (s *AppServiceImpl) Apply(ctx context.Context, spec domain.AppSpec, source []byte, dryRun bool) (*in.AppApplyResult, *in.AppDryRunResult, error) {
215 applyResult, dryResult, err := s.core.Apply(ctx, spec, source, dryRun)
216 if err != nil {
217 return nil, nil, err
218 }
219 if dryResult != nil {
220 return nil, &in.AppDryRunResult{
221 App: spec.Name,
222 Valid: true,
223 Diff: dryResult.Diff,
224 }, nil
225 }
226 if applyResult == nil {
227 return nil, &in.AppDryRunResult{App: spec.Name, Valid: true}, nil
228 }
229 return &in.AppApplyResult{
230 App: applyResult.App,
231 FormerRevision: applyResult.FormerRevision,
232 ResultingRevision: applyResult.ResultingRevision,
233 Noop: applyResult.Noop,
234 Pending: applyResult.Pending,
235 Diff: applyResult.Diff,
236 IntentID: applyResult.IntentID,
237 }, nil, nil
238}
239
240// List implements in.AppService. Names whose only remaining record is a
241// retired operation journal are not apps any more and are not listed.
242func (s *AppServiceImpl) List(ctx context.Context) ([]in.AppSummary, error) {
243 apps, err := s.store.ListApps(ctx)
244 if err != nil {
245 return nil, err
246 }
247 sort.Strings(apps)
248 summaries := make([]in.AppSummary, 0, len(apps))
249 for _, app := range apps {
250 live, err := s.store.AppExists(ctx, app)
251 if err != nil {
252 return nil, err
253 }
254 if !live {
255 continue
256 }
257 summary, err := s.summary(ctx, app)
258 if err != nil {
259 return nil, err
260 }
261 summaries = append(summaries, summary)
262 }
263 return summaries, nil
264}
265
266// summary builds one list row from desired + ACTIVE + intent + the latest
267// journal outcome.
268func (s *AppServiceImpl) summary(ctx context.Context, app string) (in.AppSummary, error) {
269 desired, _, err := s.store.LoadDesired(ctx, app)
270 if err != nil {
271 return in.AppSummary{}, err
272 }
273 active, ok, err := s.store.LoadActive(ctx, app)
274 if err != nil {
275 return in.AppSummary{}, err
276 }
277 intent, err := s.store.LoadIntent(ctx, app)
278 if err != nil {
279 return in.AppSummary{}, err
280 }
281 summary := in.AppSummary{
282 App: app,
283 Desired: desired.Revision,
284 DesiredStatus: desired.Status,
285 Pending: desiredPending(desired.Revision, active, ok),
286 Stopped: intent.Stopped,
287 }
288 if ok {
289 summary.Active = active.ConvergedRevision
290 summary.Converged = active.Converged
291 }
292 latest, hasOp, err := s.store.LoadLatestOperation(ctx, app)
293 if err != nil {
294 return in.AppSummary{}, err
295 }
296 if hasOp {
297 summary.LastOutcome = latest.Outcome
298 }
299 return summary, nil
300}
301
302// Show implements in.AppService.
303func (s *AppServiceImpl) Show(ctx context.Context, app string) (*in.AppDetail, error) {
304 live, err := s.store.AppExists(ctx, app)
305 if err != nil {
306 return nil, err
307 }
308 if !live {
309 return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
310 }
311 desired, _, err := s.store.LoadDesired(ctx, app)
312 if err != nil {
313 return nil, err
314 }
315 active, ok, err := s.store.LoadActive(ctx, app)
316 if err != nil {
317 return nil, err
318 }
319 intent, err := s.store.LoadIntent(ctx, app)
320 if err != nil {
321 return nil, err
322 }
323 detail := &in.AppDetail{
324 App: app,
325 DesiredRevision: desired.Revision,
326 DesiredStatus: desired.Status,
327 Pending: desiredPending(desired.Revision, active, ok),
328 Converged: active.Converged,
329 ConvergedRevision: active.ConvergedRevision,
330 Services: map[string]in.AppServiceView{},
331 Stopped: intent.Stopped,
332 }
333 ownership, err := s.store.LoadOwnership(ctx, app)
334 if err != nil {
335 return nil, err
336 }
337 detail.Retained = retainedView(ownership)
338 for name, svc := range active.Services {
339 detail.Services[name] = in.AppServiceView{
340 EffectiveRevision: svc.EffectiveRevision,
341 Digest: svc.Digest,
342 Container: svc.Container,
343 RestartUnsafe: ownership.Services[name].RestartUnsafe,
344 }
345 }
346 latest, ok, err := s.store.LoadLatestOperation(ctx, app)
347 if err != nil {
348 return nil, err
349 }
350 if ok {
351 detail.LastOp = latest.Op
352 detail.LastOpKind = latest.Kind
353 detail.LastOutcome = latest.Outcome
354 detail.LastOpStartedAt = latest.StartedAt
355 }
356 return detail, nil
357}
358
359// desiredPending reports desired state the active revision has not
360// reached. It reads desired vs ACTIVE only: a journal outcome never
361// decides convergence.
362func desiredPending(desiredRevision string, active domain.AppActive, hasActive bool) bool {
363 if desiredRevision == "" {
364 return false
365 }
366 return !hasActive || !active.Converged || active.ConvergedRevision != desiredRevision
367}
368
369// retainedView lists the resources the app owns and would retain on
370// removal, sorted for stable output.
371func retainedView(ownership domain.AppOwnership) in.AppRetainedView {
372 view := in.AppRetainedView{}
373 for _, volume := range ownership.Volumes {
374 name := volume.RuntimeName
375 if name == "" {
376 name = volume.Name
377 }
378 view.Volumes = append(view.Volumes, name)
379 }
380 for _, secret := range ownership.Secrets {
381 view.Secrets = append(view.Secrets, secret.Path)
382 }
383 for _, image := range ownership.Images {
384 view.Images = append(view.Images, image.Reference)
385 }
386 sort.Strings(view.Volumes)
387 sort.Strings(view.Secrets)
388 sort.Strings(view.Images)
389 return view
390}
391
392// Diff implements in.AppService.
393func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff, error) {
394 desired, ok, err := s.store.LoadDesired(ctx, app)
395 if err != nil {
396 return domain.AppDiff{}, err
397 }
398 if !ok {
399 return domain.AppDiff{}, fmt.Errorf("apps: app %q has no desired state: %w", app, domain.ErrAppRevisionNotFound)
400 }
401 active, _, err := s.store.LoadActive(ctx, app)
402 if err != nil {
403 return domain.AppDiff{}, err
404 }
405 return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil
406}
407
408// Deploy implements in.AppService. It enrolls the request hand-off with the
409// lifecycle first, so a deploy arriving after shutdown began is refused before
410// any claim, and one already in flight is always scheduled or settled before
411// Shutdown returns. StartDeploy then claims and persists the operation under
412// the app lock, and the effects run in the background on the daemon context,
413// so Deploy returns the still-running journal promptly and request
414// cancellation cannot abort an in-flight replacement. A repeated idempotency
415// key is never owned twice: its stored journal replays instead of spawning a
416// second execution, and only the owner schedules any work.
417func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
418 if err := s.requireServiceScope(ctx, app, revision, service, all, true); err != nil {
419 return nil, err
420 }
421 done, err := s.beginDeploy()
422 if err != nil {
423 return nil, err
424 }
425 defer done()
426
427 started, err := s.deploy.StartDeploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey})
428 if err != nil {
429 return nil, err
430 }
431 if !started.Owned {
432 // The key already answered this request: its stored journal is the
433 // result and no workload is touched again.
434 return s.operationResult(ctx, app, started.Claim.Op, started.ReplayError())
435 }
436 if err := s.scheduleExecution(started.Claim); err != nil {
437 return s.operationResult(ctx, app, started.Claim.Op, err)
438 }
439 return s.operationResult(ctx, app, started.Claim.Op, nil)
440}
441
442// requireServiceScope refuses an app-wide deploy or restart of a
443// multi-service app unless the caller asked for it with all, so an
444// unrelated service (a database, say) is never replaced by accident. It
445// runs before any claim or idempotency key is recorded. Deploy counts the
446// selected revision — an explicit revision when one is given, the desired
447// head otherwise — plus ACTIVE, so an older multi-service revision cannot
448// slip through a single-service head, and a first deploy is covered.
449// Restart counts ACTIVE only.
450func (s *AppServiceImpl) requireServiceScope(ctx context.Context, app, revision, service string, all, includeDesired bool) error {
451 if all && service != "" {
452 return fmt.Errorf("apps: --all and --service are mutually exclusive: %w", domain.ErrAppServiceScope)
453 }
454 if all || service != "" {
455 return nil
456 }
457 names := map[string]struct{}{}
458 if includeDesired {
459 desired, err := s.loadScopeRevision(ctx, app, revision)
460 if err != nil {
461 return err
462 }
463 for _, svc := range desired.Spec.Services {
464 names[svc.Name] = struct{}{}
465 }
466 }
467 active, ok, err := s.store.LoadActive(ctx, app)
468 if err != nil {
469 return fmt.Errorf("apps: load active of app %q for service scope: %w", app, err)
470 }
471 if ok {
472 for name := range active.Services {
473 names[name] = struct{}{}
474 }
475 }
476 if len(names) <= 1 {
477 return nil
478 }
479 sorted := make([]string, 0, len(names))
480 for name := range names {
481 sorted = append(sorted, name)
482 }
483 sort.Strings(sorted)
484 return fmt.Errorf("app %s has several services (%s): pass --service NAME or --all: %w",
485 app, strings.Join(sorted, ", "), domain.ErrAppServiceScope)
486}
487
488// loadScopeRevision returns the revision a deploy would activate: the
489// explicit revision when one is selected, the desired head otherwise. A
490// missing revision counts no services: an unknown explicit revision is
491// left for StartDeploy to report, and a missing desired head means a
492// first deploy with nothing captured yet.
493func (s *AppServiceImpl) loadScopeRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error) {
494 if revision != "" {
495 rev, err := s.store.LoadRevision(ctx, app, revision)
496 if err != nil {
497 if errors.Is(err, domain.ErrAppRevisionNotFound) {
498 return domain.AppDesiredRevision{}, nil
499 }
500 return domain.AppDesiredRevision{}, fmt.Errorf("apps: load revision %q of app %q for service scope: %w", revision, app, err)
501 }
502 return rev, nil
503 }
504 desired, ok, err := s.store.LoadDesired(ctx, app)
505 if err != nil {
506 return domain.AppDesiredRevision{}, fmt.Errorf("apps: load desired of app %q for service scope: %w", app, err)
507 }
508 if !ok {
509 return domain.AppDesiredRevision{}, nil
510 }
511 return desired, nil
512}
513
514// beginDeploy enrolls one Deploy hand-off before the engine claims anything.
515// It refuses the request once Shutdown began, and otherwise guarantees Shutdown
516// waits for the hand-off (claim, schedule, or settle) to finish before state is
517// torn down, so no claim is made or settled against closed state. The returned
518// done func must be called exactly once.
519func (s *AppServiceImpl) beginDeploy() (func(), error) {
520 s.lifecycleMu.Lock()
521 defer s.lifecycleMu.Unlock()
522 if s.stopped {
523 return nil, fmt.Errorf("apps: deploy refused: daemon is shutting down: %w", domain.ErrAppStateConflict)
524 }
525 s.handoffs.Add(1)
526 return s.handoffs.Done, nil
527}
528
529// Stop implements in.AppService.
530func (s *AppServiceImpl) Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
531 result, err := s.deploy.Stop(ctx, app, idempotencyKey)
532 if result == nil {
533 return nil, err
534 }
535 return s.operationResult(ctx, app, result.Op, err)
536}
537
538// Start implements in.AppService.
539func (s *AppServiceImpl) Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
540 result, err := s.deploy.Start(ctx, app, idempotencyKey)
541 if result == nil {
542 return nil, err
543 }
544 return s.operationResult(ctx, app, result.Op, err)
545}
546
547// Restart implements in.AppService.
548func (s *AppServiceImpl) Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
549 if err := s.requireServiceScope(ctx, app, "", service, all, false); err != nil {
550 return nil, err
551 }
552 result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)
553 if result == nil {
554 return nil, err
555 }
556 return s.operationResult(ctx, app, result.Op, err)
557}
558
559// Remove implements in.AppService.
560func (s *AppServiceImpl) Remove(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
561 result, err := s.deploy.Remove(ctx, app, idempotencyKey)
562 if result == nil {
563 return nil, err
564 }
565 return s.operationResult(ctx, app, result.Op, err)
566}
567
568// operationResult loads the journal an engine call produced. A call that
569// failed after its journal was claimed returns both, so callers can
570// inspect the recorded outcome instead of losing it.
571func (s *AppServiceImpl) operationResult(ctx context.Context, app, opID string, callErr error) (*domain.AppOperation, error) {
572 if opID == "" {
573 return nil, callErr
574 }
575 op, err := s.store.LoadOperation(ctx, app, opID)
576 if err != nil {
577 if callErr != nil {
578 return nil, callErr
579 }
580 return nil, err
581 }
582 if callErr != nil {
583 return &op, callErr
584 }
585 return &op, nil
586}
587
588// OperationByKey implements in.AppService.
589func (s *AppServiceImpl) OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error) {
590 op, err := s.store.LoadOperation(ctx, app, key)
591 if err != nil {
592 return nil, err
593 }
594 return &op, nil
595}
596
597// ListSecrets implements in.AppService without reading secret values. A
598// name with no live identity is not found; ownership-only apps remain live
599// and report their registrations (possibly none).
600func (s *AppServiceImpl) ListSecrets(ctx context.Context, app, service string) ([]in.AppSecretMetadata, error) {
601 live, err := s.store.AppExists(ctx, app)
602 if err != nil {
603 return nil, fmt.Errorf("list app secrets: check app %q: %w", app, err)
604 }
605 if !live {
606 return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
607 }
608 entries := []in.AppSecretMetadata{}
609 desired, ok, err := s.store.LoadDesired(ctx, app)
610 if err != nil {
611 return nil, fmt.Errorf("list app secrets: load desired for %q: %w", app, err)
612 }
613 if ok {
614 for _, svc := range desired.Spec.Services {
615 entries = appendSecretMetadata(entries, service, svc.Name, svc.Secrets, "desired")
616 }
617 }
618 active, ok, err := s.store.LoadActive(ctx, app)
619 if err != nil {
620 return nil, fmt.Errorf("list app secrets: load active for %q: %w", app, err)
621 }
622 if ok {
623 for svcName, svc := range active.Services {
624 entries = appendSecretMetadata(entries, service, svcName, svc.Spec.Secrets, "active")
625 }
626 }
627 sort.Slice(entries, func(i, j int) bool {
628 a, b := entries[i], entries[j]
629 if a.Service != b.Service {
630 return a.Service < b.Service
631 }
632 if a.Key != b.Key {
633 return a.Key < b.Key
634 }
635 if a.Source != b.Source {
636 return a.Source < b.Source
637 }
638 return a.Name < b.Name
639 })
640 return entries, nil
641}
642
643func appendSecretMetadata(entries []in.AppSecretMetadata, filter, service string, secrets map[string]string, source string) []in.AppSecretMetadata {
644 if filter != "" && filter != service {
645 return entries
646 }
647 for key, name := range secrets {
648 entries = append(entries, in.AppSecretMetadata{Service: service, Key: key, Name: name, Source: source, Presence: "unknown"})
649 }
650 return entries
651}
652
653// SetSecrets implements in.AppService.
654func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {
655 if service == "" {
656 return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec)
657 }
658 registered, err := s.secretNames(ctx, app, service)
659 if err != nil {
660 return err
661 }
662 for key, value := range values {
663 name, ok := registered[key]
664 if !ok {
665 return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing)
666 }
667 path, err := s.secretPath(ctx, app, service, name)
668 if err != nil {
669 return err
670 }
671 if err := s.secrets.SetSecret(ctx, path, value); err != nil {
672 return fmt.Errorf("apps: write secret %q: %w", key, err)
673 }
674 }
675 return nil
676}
677
678// DeleteSecret implements in.AppService.
679func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key string) error {
680 if service == "" || key == "" {
681 return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec)
682 }
683 registered, err := s.secretNames(ctx, app, service)
684 if err != nil {
685 return err
686 }
687 name, ok := registered[key]
688 if !ok {
689 return fmt.Errorf("apps: secret %q not registered for %s/%s: %w", key, app, service, domain.ErrAppSecretMissing)
690 }
691 // Refused while the name is still referenced by desired or active.
692 if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil {
693 return err
694 } else if referenced {
695 return fmt.Errorf("apps: secret %q still referenced: %w", key, domain.ErrAppStateConflict)
696 }
697 path, err := s.secretPath(ctx, app, service, name)
698 if err != nil {
699 return err
700 }
701 return s.secrets.DeleteSecret(ctx, path)
702}
703
704// secretPath returns the UUID-keyed pass path for one secret name.
705// Empty ID means legacy name-keyed paths (records predating UUIDs).
706func (s *AppServiceImpl) secretPath(ctx context.Context, app, service, name string) (string, error) {
707 ownership, err := s.store.LoadOwnership(ctx, app)
708 if err != nil {
709 return "", err
710 }
711 return domain.AppSecretPathForID(ownership.ID, app, service, name), nil
712}
713
714// secretNames returns env-key → secret-name registrations.
715func (s *AppServiceImpl) secretNames(ctx context.Context, app, service string) (map[string]string, error) {
716 merged := map[string]string{}
717 desired, ok, err := s.store.LoadDesired(ctx, app)
718 if err != nil {
719 return nil, err
720 }
721 if ok {
722 for _, svc := range desired.Spec.Services {
723 if svc.Name == service {
724 for envKey, name := range svc.Secrets {
725 merged[envKey] = name
726 }
727 }
728 }
729 }
730 active, ok, err := s.store.LoadActive(ctx, app)
731 if err != nil {
732 return nil, err
733 }
734 if ok {
735 for name, svc := range active.Services {
736 if name == service {
737 for envKey, secretName := range svc.Spec.Secrets {
738 merged[envKey] = secretName
739 }
740 }
741 }
742 }
743 if len(merged) == 0 {
744 return nil, fmt.Errorf("apps: service %q has no registered secrets: %w", service, domain.ErrAppSecretMissing)
745 }
746 return merged, nil
747}
748
749// secretReferenced reports desired/active references to a secret name.
750func (s *AppServiceImpl) secretReferenced(ctx context.Context, app, service, name string) (bool, error) {
751 desired, ok, err := s.store.LoadDesired(ctx, app)
752 if err != nil {
753 return false, err
754 }
755 if ok {
756 for _, svc := range desired.Spec.Services {
757 if svc.Name != service {
758 continue
759 }
760 for _, secretName := range svc.Secrets {
761 if secretName == name {
762 return true, nil
763 }
764 }
765 }
766 }
767 active, ok, err := s.store.LoadActive(ctx, app)
768 if err != nil {
769 return false, err
770 }
771 if ok {
772 for svcName, svc := range active.Services {
773 if svcName != service {
774 continue
775 }
776 for _, secretName := range svc.Spec.Secrets {
777 if secretName == name {
778 return true, nil
779 }
780 }
781 }
782 }
783 return false, nil
784}