v3.0.0 f19f453

app_service.go RawHistoryFolder

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}

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