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