main bac21c6

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

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