main bac21c6

service.go RawHistoryFolder

1// Package apps implements the desired-state use case: manifest apply,
2// dry-run validation, desired/active inspection, and reservation
3// management. It performs zero workload, pull, or secret-value effects;
4// all persistence goes through the out.AppState boundary.
5package apps
6
7import (
8	"context"
9	"crypto/sha256"
10	"encoding/hex"
11	"errors"
12	"fmt"
13	"sync"
14	"time"
15
16	"github.com/bnema/zerowrap"
17	"github.com/google/uuid"
18
19	"github.com/bnema/gordon/internal/boundaries/out"
20	"github.com/bnema/gordon/internal/domain"
21)
22
23// Service orchestrates app desired-state transitions.
24type Service struct {
25	store     out.AppApplyStore
26	log       zerowrap.Logger
27	listeners map[string]domain.EntryPointListener
28	// barrier is the process-wide GC barrier. An apply holds the shared
29	// lease from validation through durable publication, so prune can
30	// never snapshot between a staged intent and its materialization.
31	barrier     out.GCBarrier
32	imagePolicy domain.ImageSourcePolicy
33	// bindMu guards bindPolicies. Reload replaces the whole map
34	// atomically; applies read the current map without holding the lock.
35	bindMu       sync.RWMutex
36	bindPolicies map[string]domain.AppBindPolicy
37	// deviceMu guards devicePolicies. Reload replaces the whole map
38	// atomically; applies read the current map without holding the lock.
39	deviceMu       sync.RWMutex
40	devicePolicies map[string]domain.AppDevicePolicy
41	// capabilityMu guards capabilityPolicies, replaced atomically on reload.
42	capabilityMu       sync.RWMutex
43	capabilityPolicies map[string]domain.AppCapabilityPolicy
44}
45
46// NewService creates the apps use case over an AppState store.
47func NewService(store out.AppApplyStore, log zerowrap.Logger) *Service {
48	return &Service{store: store, log: log}
49}
50
51// WithGCBarrier wires the process-wide GC barrier. Nil is valid for tests
52// that never run prune.
53func (s *Service) WithGCBarrier(barrier out.GCBarrier) *Service {
54	s.barrier = barrier
55	return s
56}
57
58// WithImagePolicy supplies the installation registry policy enforced while
59// validating manifests, before any desired state is persisted.
60func (s *Service) WithImagePolicy(policy domain.ImageSourcePolicy) *Service {
61	s.imagePolicy = policy
62	return s
63}
64
65// WithBindPolicies supplies the initial administrative bind policies.
66func (s *Service) WithBindPolicies(policies map[string]domain.AppBindPolicy) *Service {
67	s.SetBindPolicies(policies)
68	return s
69}
70
71// SetBindPolicies atomically replaces the administrative bind policies used
72// to authorize manifest binds. The map is copied so later caller mutation
73// cannot race an in-flight apply.
74func (s *Service) SetBindPolicies(policies map[string]domain.AppBindPolicy) {
75	copied := make(map[string]domain.AppBindPolicy, len(policies))
76	for name, policy := range policies {
77		copied[name] = policy
78	}
79	s.bindMu.Lock()
80	s.bindPolicies = copied
81	s.bindMu.Unlock()
82}
83
84// snapshotBindPolicies returns the current policy map. SetBindPolicies never
85// mutates a published map, so the snapshot stays safe after the lock drops.
86func (s *Service) snapshotBindPolicies() map[string]domain.AppBindPolicy {
87	s.bindMu.RLock()
88	defer s.bindMu.RUnlock()
89	return s.bindPolicies
90}
91
92// WithDevicePolicies supplies the initial administrative device policies.
93func (s *Service) WithDevicePolicies(policies map[string]domain.AppDevicePolicy) *Service {
94	s.SetDevicePolicies(policies)
95	return s
96}
97
98// SetDevicePolicies atomically replaces the administrative device policies
99// used to authorize manifest devices. The map and its slices are copied so
100// later caller mutation cannot race an in-flight apply.
101func (s *Service) SetDevicePolicies(policies map[string]domain.AppDevicePolicy) {
102	copied := make(map[string]domain.AppDevicePolicy, len(policies))
103	for name, policy := range policies {
104		policy.CDI = append([]string(nil), policy.CDI...)
105		policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
106		policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
107		copied[name] = policy
108	}
109	s.deviceMu.Lock()
110	s.devicePolicies = copied
111	s.deviceMu.Unlock()
112}
113
114// snapshotDevicePolicies returns the current policy map. SetDevicePolicies
115// never mutates a published map, so the snapshot stays safe after the
116// lock drops.
117func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy {
118	s.deviceMu.RLock()
119	defer s.deviceMu.RUnlock()
120	return s.devicePolicies
121}
122
123// WithCapabilityPolicies supplies the initial administrative capability policies.
124func (s *Service) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *Service {
125	s.SetCapabilityPolicies(policies)
126	return s
127}
128
129// SetCapabilityPolicies atomically replaces the administrative capability
130// policies used to authorize manifest capabilities. The map and its slices
131// are copied so later caller mutation cannot race an in-flight apply.
132func (s *Service) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
133	copied := make(map[string]domain.AppCapabilityPolicy, len(policies))
134	for name, policy := range policies {
135		policy.Add = append([]string(nil), policy.Add...)
136		policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
137		policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
138		copied[name] = policy
139	}
140	s.capabilityMu.Lock()
141	s.capabilityPolicies = copied
142	s.capabilityMu.Unlock()
143}
144
145// snapshotCapabilityPolicies returns the current policy map.
146// SetCapabilityPolicies never mutates a published map.
147func (s *Service) snapshotCapabilityPolicies() map[string]domain.AppCapabilityPolicy {
148	s.capabilityMu.RLock()
149	defer s.capabilityMu.RUnlock()
150	return s.capabilityPolicies
151}
152
153// noopLease is a GC lease for an unwired barrier.
154type noopLease struct{}
155
156func (noopLease) Release() {}
157
158// acquireSharedLease takes the shared GC lease for one apply. It fails
159// closed: an apply must not publish desired state while prune holds the
160// exclusive lease.
161func (s *Service) acquireSharedLease(ctx context.Context) (out.GCLease, error) {
162	if s.barrier == nil {
163		return noopLease{}, nil
164	}
165	lease, err := s.barrier.AcquireShared(ctx)
166	if err != nil {
167		return nil, fmt.Errorf("apps: acquire GC lease: %w", err)
168	}
169	return lease, nil
170}
171
172// WithEntrypoints supplies the installation entrypoint listeners so Apply
173// can reject an L4 publish declaration that does not match the listener
174// that will actually bind it. A nil map leaves the check to projection.
175func (s *Service) WithEntrypoints(listeners map[string]domain.EntryPointListener) *Service {
176	s.listeners = listeners
177	return s
178}
179
180// ApplyResult describes one accepted (or no-op) apply.
181type ApplyResult struct {
182	App               string
183	FormerRevision    string
184	ResultingRevision string
185	Noop              bool
186	Pending           bool
187	Diff              domain.AppDiff
188	IntentID          string
189}
190
191// DryRunResult describes validation without persistence.
192type DryRunResult struct {
193	App   string
194	Valid bool
195	Diff  domain.AppDiff
196}
197
198// newRevisionID allocates a time-ordered rev- identifier (uuid v7).
199func newRevisionID() string {
200	id, err := uuid.NewV7()
201	if err != nil {
202		id = uuid.New()
203	}
204	return "rev-" + hexNoDashes(id)
205}
206
207// newIntentID allocates a time-ordered apply- identifier (uuid v7).
208func newIntentID() string {
209	id, err := uuid.NewV7()
210	if err != nil {
211		id = uuid.New()
212	}
213	return "apply-" + hexNoDashes(id)
214}
215
216// hexNoDashes renders a UUID without dashes (32 hex chars).
217func hexNoDashes(id uuid.UUID) string {
218	var buf [32]byte
219	hex.Encode(buf[:], id[:])
220	return string(buf[:])
221}
222
223// Apply validates a normalized spec and persists it as desired state.
224// source is the raw manifest bytes (hashed for audit, never re-read).
225// dryRun validates and previews without persistence or effects.
226func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte, dryRun bool) (*ApplyResult, *DryRunResult, error) {
227	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
228		zerowrap.FieldLayer:   "usecase",
229		zerowrap.FieldUseCase: "Apply",
230		"app":                 spec.Name,
231		"dry_run":             dryRun,
232	})
233	log := zerowrap.FromCtx(ctx)
234
235	if err := spec.Validate(); err != nil {
236		return nil, nil, err
237	}
238	for _, service := range spec.Services {
239		if err := s.imagePolicy.ValidateImageSource(service.Image); err != nil {
240			return nil, nil, fmt.Errorf("apps: service %q image %q: %w", service.Name, service.Image, err)
241		}
242	}
243	if err := s.validateBindPolicies(spec); err != nil {
244		return nil, nil, err
245	}
246	if err := s.validateDevicePolicies(spec); err != nil {
247		return nil, nil, err
248	}
249	if err := s.validateCapabilityPolicies(spec); err != nil {
250		return nil, nil, err
251	}
252	if err := s.validateEntrypointCompatibility(spec); err != nil {
253		return nil, nil, err
254	}
255	// Hold the shared GC lease from validation through publication: prune
256	// must never observe a staged-but-unmaterialized apply.
257	lease, err := s.acquireSharedLease(ctx)
258	if err != nil {
259		return nil, nil, err
260	}
261	defer lease.Release()
262	prepared, err := s.prepareApply(ctx, spec)
263	if err != nil {
264		return nil, nil, err
265	}
266	if prepared.noop {
267		log.Info().Str("revision", prepared.desired.Revision).Msg("apps: no-change apply, idempotent")
268		return &ApplyResult{
269			App:               spec.Name,
270			FormerRevision:    prepared.desired.Revision,
271			ResultingRevision: prepared.desired.Revision,
272			Noop:              true,
273			Diff:              prepared.diff,
274		}, nil, nil
275	}
276	if dryRun {
277		return nil, &DryRunResult{
278			App:   spec.Name,
279			Valid: true,
280			Diff:  prepared.diff,
281		}, nil
282	}
283	return s.persistApply(ctx, spec, source, prepared)
284}
285
286// validateBindPolicies resolves every declared bind against the
287// administrative bind policies before any desired state is persisted.
288// Errors name only the mount, app, and service: policy source paths never
289// appear in apply errors.
290func (s *Service) validateBindPolicies(spec domain.AppSpec) error {
291	policies := s.snapshotBindPolicies()
292	for _, svc := range spec.Services {
293		for _, bind := range svc.Binds {
294			policy, ok := policies[bind.Name]
295			if !ok {
296				return fmt.Errorf("%w: app %q service %q mount %q: no administrative mount policy configured", domain.ErrBindPolicy, spec.Name, svc.Name, bind.Name)
297			}
298			if _, err := policy.ResolveAppBind(spec.Name, svc.Name, bind); err != nil {
299				return fmt.Errorf("%w: app %q service %q mount %q: refused by administrative mount policy", domain.ErrBindPolicy, spec.Name, svc.Name, bind.Name)
300			}
301		}
302	}
303	return nil
304}
305
306// validateDevicePolicies resolves every declared device against the
307// administrative device policies before any desired state is persisted.
308// It runs the same union resolution as activation: duplicate CDI IDs
309// across logical resources fail here, not later at preflight. Errors
310// name only the device, app, and service with an actionable hint:
311// host inventory never appears in apply errors.
312func (s *Service) validateDevicePolicies(spec domain.AppSpec) error {
313	policies := s.snapshotDevicePolicies()
314	for _, svc := range spec.Services {
315		for _, device := range svc.Devices {
316			policy, ok := policies[device]
317			if !ok {
318				return fmt.Errorf("%w: app %q service %q device %q: no administrative device policy configured (declare [app_devices.%s] with allowed_apps/allowed_services)", domain.ErrDevicePolicy, spec.Name, svc.Name, device, device)
319			}
320			if _, err := policy.ResolveAppDevice(spec.Name, svc.Name); err != nil {
321				return fmt.Errorf("%w: app %q service %q device %q: refused by administrative device policy (check allowed_apps/allowed_services)", domain.ErrDevicePolicy, spec.Name, svc.Name, device)
322			}
323		}
324		// The union check catches duplicate CDI IDs granted under two
325		// logical names; single-device errors above already named the
326		// device, so this wraps only the cross-device conflict.
327		if _, err := domain.ResolveAppDevices(spec.Name, svc.Name, svc.Devices, policies); err != nil {
328			return fmt.Errorf("apps: %w", err)
329		}
330	}
331	return nil
332}
333
334// validateCapabilityPolicies resolves every declared capability policy
335// against the administrative capability policies before any desired state
336// is persisted. Errors name only the policy, app, and service with an
337// actionable hint.
338func (s *Service) validateCapabilityPolicies(spec domain.AppSpec) error {
339	policies := s.snapshotCapabilityPolicies()
340	for _, svc := range spec.Services {
341		for _, name := range svc.Capabilities {
342			policy, ok := policies[name]
343			if !ok {
344				return fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured (declare [app_capabilities.%s] with add/allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name, name)
345			}
346			if _, err := policy.ResolveAppCapabilities(spec.Name, svc.Name); err != nil {
347				return fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy (check allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name)
348			}
349		}
350	}
351	return nil
352}
353
354// validateEntrypointCompatibility checks every declared TCP/UDP interface
355// against the installation entrypoint listeners before any persistence or
356// workload effect. The runtime binds the entrypoint address, so a publish
357// bind that differs from it (for example loopback) must be rejected
358// rather than silently exposed on the public listener.
359func (s *Service) validateEntrypointCompatibility(spec domain.AppSpec) error {
360	if s.listeners == nil {
361		return nil
362	}
363	for _, svc := range spec.Services {
364		for _, t := range svc.TCP {
365			if err := s.checkPublish(svc.Name, "tcp", t.Entrypoint, t.Publish, domain.NetworkProtocolTCP); err != nil {
366				return err
367			}
368		}
369		for _, u := range svc.UDP {
370			if err := s.checkPublish(svc.Name, "udp", u.Entrypoint, u.Publish, domain.NetworkProtocolUDP); err != nil {
371				return err
372			}
373		}
374	}
375	return nil
376}
377
378func (s *Service) checkPublish(service, kind, entrypoint, publish string, transport domain.NetworkProtocol) error {
379	listener, ok := s.listeners[entrypoint]
380	if !ok {
381		return fmt.Errorf("%w: service %q %s interface references unknown entrypoint %q", domain.ErrInvalidAppSpec, service, kind, entrypoint)
382	}
383	if err := domain.ValidatePublishForListener(publish, transport, listener, entrypoint); err != nil {
384		return fmt.Errorf("apps: service %q: %w", service, err)
385	}
386	return nil
387}
388
389// applyPrepared carries validated apply inputs through to persistence.
390type applyPrepared struct {
391	desired      domain.AppDesiredRevision
392	diff         domain.AppDiff
393	reservations []domain.AppListenerReservation
394	noop         bool
395}
396
397// prepareApply validates, recovers, checks conflicts, detects no-ops.
398// It performs no writes.
399func (s *Service) prepareApply(ctx context.Context, spec domain.AppSpec) (*applyPrepared, error) {
400	if err := s.store.Recover(ctx); err != nil {
401		return nil, fmt.Errorf("apps: recover before mutation: %w", err)
402	}
403	checkpoint, err := s.store.LoadCheckpoint(ctx)
404	if err != nil {
405		return nil, err
406	}
407	reservations := domain.ReservationsFor(spec)
408	desired, hasDesired, err := s.store.LoadDesired(ctx, spec.Name)
409	if err != nil {
410		return nil, err
411	}
412	active, _, err := s.store.LoadActive(ctx, spec.Name)
413	if err != nil {
414		return nil, err
415	}
416	if err := domain.CheckReservations(checkpoint.Reservations, reservations, spec.Name); err != nil {
417		return nil, err
418	}
419	effective, err := activeSpec(ctx, s.store, active, spec.Env)
420	if err != nil {
421		return nil, err
422	}
423	diff := domain.DiffAppSpec(spec, effective)
424	if hasDesired && specsEqual(desired.Spec, spec) {
425		return &applyPrepared{desired: desired, diff: diff, reservations: reservations, noop: true}, nil
426	}
427	return &applyPrepared{desired: desired, diff: diff, reservations: reservations}, nil
428}
429
430// persistApply durably accepts one apply.
431func (s *Service) persistApply(ctx context.Context, spec domain.AppSpec, source []byte, prepared *applyPrepared) (*ApplyResult, *DryRunResult, error) {
432	log := zerowrap.FromCtx(ctx)
433	sourceHash := sha256.Sum256(source)
434	supersedes := prepared.desired.Revision
435	intent := domain.AppApplyIntent{
436		Intent:       newIntentID(),
437		App:          spec.Name,
438		Revision:     newRevisionID(),
439		Supersedes:   supersedes,
440		SourceSHA256: hex.EncodeToString(sourceHash[:]),
441		Spec:         spec,
442		Reservations: prepared.reservations,
443		CreatedAt:    time.Now().UTC(),
444	}
445	if err := s.store.AcceptApply(ctx, intent); err != nil {
446		return nil, nil, fmt.Errorf("apps: %w", err)
447	}
448	log.Info().Str("revision", intent.Revision).Str("intent", intent.Intent).Msg("apps: apply accepted")
449	return &ApplyResult{
450		App:               spec.Name,
451		FormerRevision:    supersedes,
452		ResultingRevision: intent.Revision,
453		Pending:           true,
454		Diff:              prepared.diff,
455		IntentID:          intent.Intent,
456	}, nil, nil
457}
458
459// revisionLoader is the store subset activeSpec needs.
460type revisionLoader interface {
461	LoadRevision(ctx context.Context, app, revision string) (domain.AppDesiredRevision, error)
462}
463
464// activeSpec rebuilds an AppSpec view from effective definitions for
465// diffing, including the app env of the revisions services run from.
466func activeSpec(ctx context.Context, store revisionLoader, active domain.AppActive, desiredEnv map[string]string) (domain.AppSpec, error) {
467	spec, err := domain.ActiveAppSpec(active, desiredEnv, func(revision string) (map[string]string, error) {
468		rev, err := store.LoadRevision(ctx, active.App, revision)
469		if errors.Is(err, domain.ErrAppRevisionNotFound) {
470			return nil, nil
471		}
472		if err != nil {
473			return nil, err
474		}
475		return rev.Spec.Env, nil
476	})
477	if err != nil {
478		return domain.AppSpec{}, fmt.Errorf("apps: rebuild active spec: %w", err)
479	}
480	return spec, nil
481}
482
483// specsEqual compares normalized specs for no-op detection.
484func specsEqual(a, b domain.AppSpec) bool {
485	diff := domain.DiffAppSpec(a, b)
486	return len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0
487}

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