v3.0.0-alpha.1 6fdddb8

app_service.go RawHistoryFolder

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}

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