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.AppApplyStore
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.AppApplyStore, 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 durably accepts one 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.AcceptApply(ctx, intent); err != nil {
385 return nil, nil, fmt.Errorf("apps: %w", err)
386 }
387 log.Info().Str("revision", intent.Revision).Str("intent", intent.Intent).Msg("apps: apply accepted")
388 return &ApplyResult{
389 App: spec.Name,
390 FormerRevision: supersedes,
391 ResultingRevision: intent.Revision,
392 Pending: true,
393 Diff: prepared.diff,
394 IntentID: intent.Intent,
395 }, nil, nil
396}
397
398// activeSpec rebuilds an AppSpec view from effective definitions for diffing.
399func activeSpec(active domain.AppActive) domain.AppSpec {
400 spec := domain.AppSpec{Name: active.App, Env: map[string]string{}, Networks: append([]domain.AppSharedNetwork(nil), active.Networks...)}
401 for name, svc := range active.Services {
402 svcSpec := svc.Spec
403 svcSpec.Name = name
404 spec.Services = append(spec.Services, svcSpec)
405 }
406 return spec
407}
408
409// specsEqual compares normalized specs for no-op detection.
410func specsEqual(a, b domain.AppSpec) bool {
411 diff := domain.DiffAppSpec(a, b)
412 return len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0
413}