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}