v3.0.0-alpha.1 6fdddb8

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

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