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}