v3.4.0 593c1c3

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

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