1package app
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "strconv"
8
9 "github.com/bnema/zerowrap"
10
11 "github.com/bnema/gordon/internal/adapters/out/appsecrets"
12 "github.com/bnema/gordon/internal/adapters/out/appstate"
13 "github.com/bnema/gordon/internal/adapters/out/httpprober"
14 "github.com/bnema/gordon/internal/adapters/out/imageref"
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17 "github.com/bnema/gordon/internal/usecase/apps"
18 "github.com/bnema/gordon/internal/usecase/apptraffic"
19 "github.com/bnema/gordon/internal/usecase/deployment"
20 "github.com/bnema/gordon/internal/usecase/health"
21)
22
23// appDeployEngine is the deployment-engine subset the daemon-owned app
24// service executes through. It mirrors the usecase's own port so the
25// composition root stays decoupled from engine internals.
26type appDeployEngine interface {
27 StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error)
28 AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error
29 ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error)
30 Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
31 Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
32 Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)
33 Remove(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
34}
35
36// newAppDaemonService builds the daemon-owned app administration service.
37// ctx is the daemon/supervisor lifecycle context: background deploy
38// executions derive from it and are cancelled by Shutdown during graceful
39// teardown. It is deliberately never an HTTP request context, so request
40// cancellation cannot abort an in-flight replacement.
41func newAppDaemonService(ctx context.Context, store out.AppState, deploy appDeployEngine, secrets out.SecretWriter, log zerowrap.Logger) *apps.AppServiceImpl {
42 return apps.NewAppServiceImpl(store, deploy, secrets, log).WithDaemonContext(ctx)
43}
44
45// initApps wires the single v3 app engine: bbolt state, image digests,
46// pass-backed secrets, deployment/lifecycle execution, and traffic
47// activation. The daemon is the sole app-state writer; CLI reaches the
48// engine only through the admin /apps surface.
49func (si *serviceInit) initApps() error {
50 dataDir := resolveDataDir(si.cfg.Server.DataDir)
51 store, err := appstate.NewStore(dataDir, si.log)
52 if err != nil {
53 return si.log.WrapErr(err, "failed to open app state")
54 }
55 store.WithRevisionRetention(si.cfg.Apps.RevisionRetention)
56 si.svc.appState = store
57 observeManagedContainers(si.svc.metrics, store, si.log)
58 // One process-wide GC barrier owns the ordering between resource
59 // acquisition/publication and destructive prune.
60 si.svc.gcBarrier = newGCBarrier()
61 if si.svc.backupSvc != nil {
62 si.svc.backupSvc.WithAppState(store)
63 }
64 if si.svc.volumeBackupSvc != nil {
65 si.svc.volumeBackupSvc.WithAppState(store)
66 }
67 if si.svc.imageSvc != nil {
68 si.svc.imageSvc.WithPrunePorts(store, si.svc.runtime, si.svc.gcBarrier)
69 }
70 if si.svc.volumeSvc != nil {
71 si.svc.volumeSvc.WithPrunePorts(store, si.svc.runtime, si.svc.gcBarrier)
72 }
73 registryDomain := si.svc.configSvc.GetRegistryDomain()
74 imagePolicy := domain.ImageSourcePolicy{
75 AllowedRegistries: si.cfg.Images.AllowedRegistries,
76 RequireDigest: si.cfg.Images.RequireDigest,
77 InstallationRegistry: registryDomain,
78 }
79 if err := imagePolicy.Validate(); err != nil {
80 return fmt.Errorf("invalid image registry policy: %w", err)
81 }
82 resolver := imageref.NewResolver(registryDomain, si.svc.manifestStorage, nil).WithPolicy(imagePolicy)
83 // The publisher is the single serialized HTTP/L4 boundary: raw
84 // activations, fail-closed recovery withdrawal, and reload all go
85 // through it. It resolves writers/permission paths late-bound.
86 si.svc.appTrafficPublisher = newAppTrafficPublisher(si.svc, si.cfg)
87 limits, err := containerResourceLimits(si.cfg)
88 if err != nil {
89 return err
90 }
91 secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir)
92 if err != nil {
93 return si.log.WrapErr(err, "failed to prepare app runtime safety")
94 }
95 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{
96 State: store,
97 Runtime: si.svc.runtime,
98 Images: resolver,
99 Secrets: si.svc.serviceSecretProvider,
100 Registry: deployment.RegistryConfig{
101 Domain: registryDomain,
102 PullAddress: net.JoinHostPort("127.0.0.1", strconv.Itoa(si.svc.configSvc.GetRegistryPort())),
103 Username: si.svc.internalRegUser,
104 Password: si.svc.internalRegPass,
105 },
106 Networks: deployment.NetworkConfig{
107 Prefix: si.cfg.NetworkIsolation.Prefix,
108 Internal: si.cfg.NetworkIsolation.Internal,
109 },
110 Limits: limits,
111 ImagePolicy: imagePolicy,
112 Traffic: si.svc.appTrafficPublisher,
113 SecretFiles: secretFiles,
114 Guard: guard,
115 }, si.log).WithGCBarrier(si.svc.gcBarrier)
116 // createServices runs before any listener starts, so no deploy can
117 // race this sweep of secret files left by crashes or downgrades.
118 if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil {
119 si.log.Warn().Err(err).Msg("failed to sweep orphan secret files")
120 }
121 si.svc.appActivator = apptraffic.NewActivator(si.log)
122 mountPolicies, err := buildAppMountPolicies(si.cfg)
123 if err != nil {
124 return err
125 }
126 devicePolicies, err := buildAppDevicePolicies(si.cfg)
127 if err != nil {
128 return err
129 }
130 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
131 si.svc.appDeploySvc.SetDevicePolicies(devicePolicies)
132 appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
133 WithEntrypoints(appEntrypointListeners(si.cfg)).
134 WithGCBarrier(si.svc.gcBarrier).
135 WithImagePolicy(imagePolicy).
136 WithBindPolicies(mountPolicies).
137 WithDevicePolicies(devicePolicies)
138 si.svc.appSvcImpl = appSvcImpl
139 si.svc.appSvc = appSvcImpl
140 // Health checks resolve from ACTIVE state (loopback backends).
141 si.svc.healthSvc = health.NewService(store, si.svc.runtime, httpprober.New(), si.log)
142 // ACTIVE-derived host index for the proxy: rebuilt after every
143 // activation and at boot. Wired here (store is open); the proxy
144 // itself was built earlier in initRuntimeAndProxy.
145 si.svc.appHostIndex = apptraffic.NewHostIndex()
146 if si.svc.proxySvc != nil {
147 si.svc.proxySvc.WithAppTargets(si.svc.appHostIndex)
148 }
149 return nil
150}
151
152// appRouteSource adapts the ACTIVE-derived host index plus installation
153// external routes to the route-source interfaces (PKI AppRoutes,
154// public-TLS RouteSource). bbolt stays authoritative; this adapter only
155// projects. Fail-closed: unwired index yields zero app hosts. The index
156// is late-bound (built after PKI/public-TLS), so it resolves per call.
157type appRouteSource struct {
158 index func() *apptraffic.HostIndex
159 external func() map[string]string
160}
161
162// AppHosts implements out.AppHostSource.
163func (s *appRouteSource) AppHosts() []out.AppHost {
164 if s == nil || s.index == nil {
165 return nil
166 }
167 index := s.index()
168 if index == nil {
169 return nil
170 }
171 return index.AppHosts()
172}
173
174// GetExternalRoutes implements the installation half of out.AppRoutes
175// and the public-TLS RouteSource.
176func (s *appRouteSource) GetExternalRoutes() map[string]string {
177 if s == nil || s.external == nil {
178 return nil
179 }
180 return s.external()
181}
182
183// GetRoutes implements the public-TLS RouteSource as a consumer-local
184// adapter: host-only entries, Image explicitly unused (no app meaning).
185func (s *appRouteSource) GetRoutes(context.Context) []domain.Route {
186 hosts := s.AppHosts()
187 routes := make([]domain.Route, 0, len(hosts))
188 for _, h := range hosts {
189 routes = append(routes, domain.Route{Domain: h.Host})
190 }
191 return routes
192}
193
194// appRoutes returns the shared ACTIVE-derived route source, late-bound
195// to the host index built in initApps.
196func (si *serviceInit) appRoutes() *appRouteSource {
197 return &appRouteSource{
198 index: func() *apptraffic.HostIndex { return si.svc.appHostIndex },
199 external: func() map[string]string { return si.svc.configSvc.GetExternalRoutes() },
200 }
201}
202
203// appEntrypointPolicies maps installation entrypoints to the projection
204// policy (trusted CIDRs, raw-fallback behavior, transport limits).
205func appEntrypointPolicies(cfg Config) map[string]apptraffic.EntrypointPolicy {
206 policies := make(map[string]apptraffic.EntrypointPolicy, len(cfg.EntryPoints))
207 for name, entry := range cfg.EntryPoints {
208 policies[name] = apptraffic.EntrypointPolicy{
209 Name: name,
210 Address: entry.Address,
211 Protocol: entry.Protocol,
212 TrustedCIDRs: entry.TrustedCIDRs,
213 RawFallback: entry.RawFallback,
214 RawFallbackTrustedCIDRs: entry.RawFallbackTrustedCIDRs,
215 AllowPublicRawFallback: entry.AllowPublicRawFallback,
216 }
217 }
218 return policies
219}
220
221// appEntrypointListeners maps installation entrypoints to the canonical
222// listener an app L4 publish declaration must match exactly.
223func appEntrypointListeners(cfg Config) map[string]domain.EntryPointListener {
224 listeners := make(map[string]domain.EntryPointListener, len(cfg.EntryPoints))
225 for name, entry := range cfg.EntryPoints {
226 listeners[name] = domain.EntryPointListener{Address: entry.Address, Protocol: entry.Protocol}
227 }
228 return listeners
229}
230
231// rebuildAppHostIndex re-projects ACTIVE state into the proxy host index
232// and applies the full HTTP/L4 traffic graph through the serialized
233// publisher. Call after every activation (deploy/start/restart/stop/
234// remove) and at boot; callers decide whether a failure is fatal to the
235// mutation.
236func rebuildAppHostIndex(ctx context.Context, svc *services) error {
237 if svc.appTrafficPublisher == nil {
238 return nil
239 }
240 return svc.appTrafficPublisher.RebuildTraffic(ctx)
241}
242
243// reconcileAppsAtBoot reconciles declarative apps intended to run,
244// rebuilds the proxy host index from reconciled ACTIVE state, then
245// starts the single periodic recovery monitor. Failures only warn: the
246// monitor still starts so a partially failed boot self-heals.
247func reconcileAppsAtBoot(ctx context.Context, svc *services, log zerowrap.Logger) {
248 if svc.appDeploySvc == nil {
249 return
250 }
251 if err := svc.appDeploySvc.ReconcileBoot(ctx); err != nil {
252 log.Warn().Err(err).Msg("failed to reconcile apps at boot")
253 }
254 if err := rebuildAppHostIndex(ctx, svc); err != nil {
255 log.Warn().Err(err).Msg("failed to rebuild app host index at boot")
256 }
257 // Exactly one monitor per process: a second boot reconciliation must
258 // not orphan a running loop in the daemon context.
259 if svc.appMonitor == nil {
260 svc.appMonitor = newAppMonitor(svc.appDeploySvc, log)
261 }
262 svc.appMonitor.Start(ctx)
263}