v3.4.1 de8f68e

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	effective, err := activeSpec(ctx, s.store, active, desired.Spec.Env)
406	if err != nil {
407		return domain.AppDiff{}, err
408	}
409	return domain.DiffAppSpec(desired.Spec, effective), nil
410}
411
412// Deploy implements in.AppService. It enrolls the request hand-off with the
413// lifecycle first, so a deploy arriving after shutdown began is refused before
414// any claim, and one already in flight is always scheduled or settled before
415// Shutdown returns. StartDeploy then claims and persists the operation under
416// the app lock, and the effects run in the background on the daemon context,
417// so Deploy returns the still-running journal promptly and request
418// cancellation cannot abort an in-flight replacement. A repeated idempotency
419// key is never owned twice: its stored journal replays instead of spawning a
420// second execution, and only the owner schedules any work.
421func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
422	if err := s.requireServiceScope(ctx, app, revision, service, all, true); err != nil {
423		return nil, err
424	}
425	done, err := s.beginDeploy()
426	if err != nil {
427		return nil, err
428	}
429	defer done()
430
431	started, err := s.deploy.StartDeploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey})
432	if err != nil {
433		return nil, err
434	}
435	if !started.Owned {
436		// The key already answered this request: its stored journal is the
437		// result and no workload is touched again.
438		return s.operationResult(ctx, app, started.Claim.Op, started.ReplayError())
439	}
440	if err := s.scheduleExecution(started.Claim); err != nil {
441		return s.operationResult(ctx, app, started.Claim.Op, err)
442	}
443	return s.operationResult(ctx, app, started.Claim.Op, nil)
444}
445
446// requireServiceScope refuses an app-wide deploy or restart of a
447// multi-service app unless the caller asked for it with all, so an
448// unrelated service (a database, say) is never replaced by accident. It
449// runs before any claim or idempotency key is recorded. Deploy counts the
450// selected revision — an explicit revision when one is given, the desired
451// head otherwise — plus ACTIVE, so an older multi-service revision cannot
452// slip through a single-service head, and a first deploy is covered.
453// Restart counts ACTIVE only.
454func (s *AppServiceImpl) requireServiceScope(ctx context.Context, app, revision, service string, all, includeDesired bool) error {
455	if all && service != "" {
456		return fmt.Errorf("apps: --all and --service are mutually exclusive: %w", domain.ErrAppServiceScope)
457	}
458	if all || service != "" {
459		return nil
460	}
461	names := map[string]struct{}{}
462	if includeDesired {
463		desired, err := s.loadScopeRevision(ctx, app, revision)
464		if err != nil {
465			return err
466		}
467		for _, svc := range desired.Spec.Services {
468			names[svc.Name] = struct{}{}
469		}
470	}
471	active, ok, err := s.store.LoadActive(ctx, app)
472	if err != nil {
473		return fmt.Errorf("apps: load active of app %q for service scope: %w", app, err)
474	}
475	if ok {
476		for name := range active.Services {
477			names[name] = struct{}{}
478		}
479	}
480	if len(names) <= 1 {
481		return nil
482	}
483	sorted := make([]string, 0, len(names))
484	for name := range names {
485		sorted = append(sorted, name)
486	}
487	sort.Strings(sorted)
488	return fmt.Errorf("app %s has several services (%s): pass --service NAME or --all: %w",
489		app, strings.Join(sorted, ", "), domain.ErrAppServiceScope)
490}
491
492// loadScopeRevision returns the revision a deploy would activate: the
493// explicit revision when one is selected, the desired head otherwise. A
494// missing revision counts no services: an unknown explicit revision is
495// left for StartDeploy to report, and a missing desired head means a
496// first deploy with nothing captured yet.
497func (s *AppServiceImpl) loadScopeRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error) {
498	if revision != "" {
499		rev, err := s.store.LoadRevision(ctx, app, revision)
500		if err != nil {
501			if errors.Is(err, domain.ErrAppRevisionNotFound) {
502				return domain.AppDesiredRevision{}, nil
503			}
504			return domain.AppDesiredRevision{}, fmt.Errorf("apps: load revision %q of app %q for service scope: %w", revision, app, err)
505		}
506		return rev, nil
507	}
508	desired, ok, err := s.store.LoadDesired(ctx, app)
509	if err != nil {
510		return domain.AppDesiredRevision{}, fmt.Errorf("apps: load desired of app %q for service scope: %w", app, err)
511	}
512	if !ok {
513		return domain.AppDesiredRevision{}, nil
514	}
515	return desired, nil
516}
517
518// beginDeploy enrolls one Deploy hand-off before the engine claims anything.
519// It refuses the request once Shutdown began, and otherwise guarantees Shutdown
520// waits for the hand-off (claim, schedule, or settle) to finish before state is
521// torn down, so no claim is made or settled against closed state. The returned
522// done func must be called exactly once.
523func (s *AppServiceImpl) beginDeploy() (func(), error) {
524	s.lifecycleMu.Lock()
525	defer s.lifecycleMu.Unlock()
526	if s.stopped {
527		return nil, fmt.Errorf("apps: deploy refused: daemon is shutting down: %w", domain.ErrAppStateConflict)
528	}
529	s.handoffs.Add(1)
530	return s.handoffs.Done, nil
531}
532
533// Stop implements in.AppService.
534func (s *AppServiceImpl) Stop(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
535	result, err := s.deploy.Stop(ctx, app, idempotencyKey)
536	if result == nil {
537		return nil, err
538	}
539	return s.operationResult(ctx, app, result.Op, err)
540}
541
542// Start implements in.AppService.
543func (s *AppServiceImpl) Start(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
544	result, err := s.deploy.Start(ctx, app, idempotencyKey)
545	if result == nil {
546		return nil, err
547	}
548	return s.operationResult(ctx, app, result.Op, err)
549}
550
551// Restart implements in.AppService.
552func (s *AppServiceImpl) Restart(ctx context.Context, app, service string, all bool, idempotencyKey string) (*domain.AppOperation, error) {
553	if err := s.requireServiceScope(ctx, app, "", service, all, false); err != nil {
554		return nil, err
555	}
556	result, err := s.deploy.Restart(ctx, app, service, idempotencyKey)
557	if result == nil {
558		return nil, err
559	}
560	return s.operationResult(ctx, app, result.Op, err)
561}
562
563// Remove implements in.AppService.
564func (s *AppServiceImpl) Remove(ctx context.Context, app, idempotencyKey string) (*domain.AppOperation, error) {
565	result, err := s.deploy.Remove(ctx, app, idempotencyKey)
566	if result == nil {
567		return nil, err
568	}
569	return s.operationResult(ctx, app, result.Op, err)
570}
571
572// operationResult loads the journal an engine call produced. A call that
573// failed after its journal was claimed returns both, so callers can
574// inspect the recorded outcome instead of losing it.
575func (s *AppServiceImpl) operationResult(ctx context.Context, app, opID string, callErr error) (*domain.AppOperation, error) {
576	if opID == "" {
577		return nil, callErr
578	}
579	op, err := s.store.LoadOperation(ctx, app, opID)
580	if err != nil {
581		if callErr != nil {
582			return nil, callErr
583		}
584		return nil, err
585	}
586	if callErr != nil {
587		return &op, callErr
588	}
589	return &op, nil
590}
591
592// OperationByKey implements in.AppService.
593func (s *AppServiceImpl) OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error) {
594	op, err := s.store.LoadOperation(ctx, app, key)
595	if err != nil {
596		return nil, err
597	}
598	return &op, nil
599}
600
601// ListSecrets implements in.AppService without reading secret values. A
602// name with no live identity is not found; ownership-only apps remain live
603// and report their registrations (possibly none).
604func (s *AppServiceImpl) ListSecrets(ctx context.Context, app, service string) ([]in.AppSecretMetadata, error) {
605	live, err := s.store.AppExists(ctx, app)
606	if err != nil {
607		return nil, fmt.Errorf("list app secrets: check app %q: %w", app, err)
608	}
609	if !live {
610		return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
611	}
612	entries := []in.AppSecretMetadata{}
613	desired, ok, err := s.store.LoadDesired(ctx, app)
614	if err != nil {
615		return nil, fmt.Errorf("list app secrets: load desired for %q: %w", app, err)
616	}
617	if ok {
618		for _, svc := range desired.Spec.Services {
619			entries = appendSecretMetadata(entries, service, svc.Name, svc.Secrets, "desired")
620		}
621	}
622	active, ok, err := s.store.LoadActive(ctx, app)
623	if err != nil {
624		return nil, fmt.Errorf("list app secrets: load active for %q: %w", app, err)
625	}
626	if ok {
627		for svcName, svc := range active.Services {
628			entries = appendSecretMetadata(entries, service, svcName, svc.Spec.Secrets, "active")
629		}
630	}
631	sort.Slice(entries, func(i, j int) bool {
632		a, b := entries[i], entries[j]
633		if a.Service != b.Service {
634			return a.Service < b.Service
635		}
636		if a.Key != b.Key {
637			return a.Key < b.Key
638		}
639		if a.Source != b.Source {
640			return a.Source < b.Source
641		}
642		return a.Name < b.Name
643	})
644	return entries, nil
645}
646
647func appendSecretMetadata(entries []in.AppSecretMetadata, filter, service string, secrets map[string]string, source string) []in.AppSecretMetadata {
648	if filter != "" && filter != service {
649		return entries
650	}
651	for key, name := range secrets {
652		entries = append(entries, in.AppSecretMetadata{Service: service, Key: key, Name: name, Source: source, Presence: "unknown"})
653	}
654	return entries
655}
656
657// SetSecrets implements in.AppService.
658func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {
659	if service == "" {
660		return fmt.Errorf("apps: service is required: %w", domain.ErrInvalidAppSpec)
661	}
662	registered, err := s.secretNames(ctx, app, service, "; run `gordon apps apply` first")
663	if err != nil {
664		return err
665	}
666	for key, value := range values {
667		name, ok := registered[key]
668		if !ok {
669			return fmt.Errorf("apps: secret %q is not declared for service %q of app %q; add it under [services.%s.secrets] in the manifest and run `gordon apps apply`: %w", key, service, app, service, domain.ErrAppSecretMissing)
670		}
671		path, err := s.secretPath(ctx, app, service, name)
672		if err != nil {
673			return err
674		}
675		if err := s.secrets.SetSecret(ctx, path, value); err != nil {
676			return fmt.Errorf("apps: write secret %q: %w", key, err)
677		}
678	}
679	return nil
680}
681
682// DeleteSecret implements in.AppService.
683func (s *AppServiceImpl) DeleteSecret(ctx context.Context, app, service, key string) error {
684	if service == "" || key == "" {
685		return fmt.Errorf("apps: service and key are required: %w", domain.ErrInvalidAppSpec)
686	}
687	registered, err := s.secretNames(ctx, app, service, "")
688	if err != nil {
689		return err
690	}
691	name, ok := registered[key]
692	if !ok {
693		return fmt.Errorf("apps: secret %q is not declared for service %q of app %q: %w", key, service, app, domain.ErrAppSecretMissing)
694	}
695	// Refused while the name is still referenced by desired or active.
696	if referenced, err := s.secretReferenced(ctx, app, service, name); err != nil {
697		return err
698	} else if referenced {
699		return fmt.Errorf("apps: secret %q still referenced: %w", key, domain.ErrAppStateConflict)
700	}
701	path, err := s.secretPath(ctx, app, service, name)
702	if err != nil {
703		return err
704	}
705	return s.secrets.DeleteSecret(ctx, path)
706}
707
708// secretPath returns the UUID-keyed pass path for one secret name.
709// Empty ID means legacy name-keyed paths (records predating UUIDs).
710func (s *AppServiceImpl) secretPath(ctx context.Context, app, service, name string) (string, error) {
711	ownership, err := s.store.LoadOwnership(ctx, app)
712	if err != nil {
713		return "", err
714	}
715	return domain.AppSecretPathForID(ownership.ID, app, service, name), nil
716}
717
718// secretNames returns env-key → secret-name registrations. An app with no
719// applied state, or a service absent from it, fails; hint is appended to
720// that message so each caller can suggest its own next step.
721func (s *AppServiceImpl) secretNames(ctx context.Context, app, service, hint string) (map[string]string, error) {
722	merged := map[string]string{}
723	found := false
724	desired, hasDesired, err := s.store.LoadDesired(ctx, app)
725	if err != nil {
726		return nil, err
727	}
728	if hasDesired {
729		for _, svc := range desired.Spec.Services {
730			if svc.Name == service {
731				found = true
732				for envKey, name := range svc.Secrets {
733					merged[envKey] = name
734				}
735			}
736		}
737	}
738	active, hasActive, err := s.store.LoadActive(ctx, app)
739	if err != nil {
740		return nil, err
741	}
742	if hasActive {
743		for name, svc := range active.Services {
744			if name == service {
745				found = true
746				for envKey, secretName := range svc.Spec.Secrets {
747					merged[envKey] = secretName
748				}
749			}
750		}
751	}
752	if !hasDesired && !hasActive {
753		return nil, fmt.Errorf("apps: app %q has no applied manifest%s: %w", app, hint, domain.ErrAppSecretMissing)
754	}
755	if !found {
756		return nil, fmt.Errorf("apps: service %q is not in the applied manifest of app %q%s: %w", service, app, hint, domain.ErrAppSecretMissing)
757	}
758	return merged, nil
759}
760
761// secretReferenced reports desired/active references to a secret name.
762func (s *AppServiceImpl) secretReferenced(ctx context.Context, app, service, name string) (bool, error) {
763	desired, ok, err := s.store.LoadDesired(ctx, app)
764	if err != nil {
765		return false, err
766	}
767	if ok {
768		for _, svc := range desired.Spec.Services {
769			if svc.Name != service {
770				continue
771			}
772			for _, secretName := range svc.Secrets {
773				if secretName == name {
774					return true, nil
775				}
776			}
777		}
778	}
779	active, ok, err := s.store.LoadActive(ctx, app)
780	if err != nil {
781		return false, err
782	}
783	if ok {
784		for svcName, svc := range active.Services {
785			if svcName != service {
786				continue
787			}
788			for _, secretName := range svc.Spec.Secrets {
789				if secretName == name {
790					return true, nil
791				}
792			}
793		}
794	}
795	return false, nil
796}

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