main bac21c6

app_wiring.go RawHistoryFolder

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	capabilityPolicies, err := buildAppCapabilityPolicies(si.cfg)
131	if err != nil {
132		return err
133	}
134	si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
135	si.svc.appDeploySvc.SetDevicePolicies(devicePolicies)
136	si.svc.appDeploySvc.SetCapabilityPolicies(capabilityPolicies)
137	appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
138		WithEntrypoints(appEntrypointListeners(si.cfg)).
139		WithGCBarrier(si.svc.gcBarrier).
140		WithImagePolicy(imagePolicy).
141		WithBindPolicies(mountPolicies).
142		WithDevicePolicies(devicePolicies).
143		WithCapabilityPolicies(capabilityPolicies)
144	si.svc.appSvcImpl = appSvcImpl
145	si.svc.appSvc = appSvcImpl
146	// Health checks resolve from ACTIVE state (loopback backends).
147	si.svc.healthSvc = health.NewService(store, si.svc.runtime, httpprober.New(), si.log)
148	// ACTIVE-derived host index for the proxy: rebuilt after every
149	// activation and at boot. Wired here (store is open); the proxy
150	// itself was built earlier in initRuntimeAndProxy.
151	si.svc.appHostIndex = apptraffic.NewHostIndex()
152	if si.svc.proxySvc != nil {
153		si.svc.proxySvc.WithAppTargets(si.svc.appHostIndex)
154	}
155	return nil
156}
157
158// appRouteSource adapts the ACTIVE-derived host index plus installation
159// external routes to the route-source interfaces (PKI AppRoutes,
160// public-TLS RouteSource). bbolt stays authoritative; this adapter only
161// projects. Fail-closed: unwired index yields zero app hosts. The index
162// is late-bound (built after PKI/public-TLS), so it resolves per call.
163type appRouteSource struct {
164	index    func() *apptraffic.HostIndex
165	external func() map[string]string
166}
167
168// AppHosts implements out.AppHostSource.
169func (s *appRouteSource) AppHosts() []out.AppHost {
170	if s == nil || s.index == nil {
171		return nil
172	}
173	index := s.index()
174	if index == nil {
175		return nil
176	}
177	return index.AppHosts()
178}
179
180// GetExternalRoutes implements the installation half of out.AppRoutes
181// and the public-TLS RouteSource.
182func (s *appRouteSource) GetExternalRoutes() map[string]string {
183	if s == nil || s.external == nil {
184		return nil
185	}
186	return s.external()
187}
188
189// GetRoutes implements the public-TLS RouteSource as a consumer-local
190// adapter: host-only entries, Image explicitly unused (no app meaning).
191func (s *appRouteSource) GetRoutes(context.Context) []domain.Route {
192	hosts := s.AppHosts()
193	routes := make([]domain.Route, 0, len(hosts))
194	for _, h := range hosts {
195		routes = append(routes, domain.Route{Domain: h.Host})
196	}
197	return routes
198}
199
200// appRoutes returns the shared ACTIVE-derived route source, late-bound
201// to the host index built in initApps.
202func (si *serviceInit) appRoutes() *appRouteSource {
203	return &appRouteSource{
204		index:    func() *apptraffic.HostIndex { return si.svc.appHostIndex },
205		external: func() map[string]string { return si.svc.configSvc.GetExternalRoutes() },
206	}
207}
208
209// appEntrypointPolicies maps installation entrypoints to the projection
210// policy (trusted CIDRs, raw-fallback behavior, transport limits).
211func appEntrypointPolicies(cfg Config) map[string]apptraffic.EntrypointPolicy {
212	policies := make(map[string]apptraffic.EntrypointPolicy, len(cfg.EntryPoints))
213	for name, entry := range cfg.EntryPoints {
214		policies[name] = apptraffic.EntrypointPolicy{
215			Name:                    name,
216			Address:                 entry.Address,
217			Protocol:                entry.Protocol,
218			TrustedCIDRs:            entry.TrustedCIDRs,
219			RawFallback:             entry.RawFallback,
220			RawFallbackTrustedCIDRs: entry.RawFallbackTrustedCIDRs,
221			AllowPublicRawFallback:  entry.AllowPublicRawFallback,
222		}
223	}
224	return policies
225}
226
227// appEntrypointListeners maps installation entrypoints to the canonical
228// listener an app L4 publish declaration must match exactly.
229func appEntrypointListeners(cfg Config) map[string]domain.EntryPointListener {
230	listeners := make(map[string]domain.EntryPointListener, len(cfg.EntryPoints))
231	for name, entry := range cfg.EntryPoints {
232		listeners[name] = domain.EntryPointListener{Address: entry.Address, Protocol: entry.Protocol}
233	}
234	return listeners
235}
236
237// rebuildAppHostIndex re-projects ACTIVE state into the proxy host index
238// and applies the full HTTP/L4 traffic graph through the serialized
239// publisher. Call after every activation (deploy/start/restart/stop/
240// remove) and at boot; callers decide whether a failure is fatal to the
241// mutation.
242func rebuildAppHostIndex(ctx context.Context, svc *services) error {
243	if svc.appTrafficPublisher == nil {
244		return nil
245	}
246	return svc.appTrafficPublisher.RebuildTraffic(ctx)
247}
248
249// reconcileAppsAtBoot reconciles declarative apps intended to run,
250// rebuilds the proxy host index from reconciled ACTIVE state, then
251// starts the single periodic recovery monitor. Failures only warn: the
252// monitor still starts so a partially failed boot self-heals.
253func reconcileAppsAtBoot(ctx context.Context, svc *services, log zerowrap.Logger) {
254	if svc.appDeploySvc == nil {
255		return
256	}
257	if err := svc.appDeploySvc.ReconcileBoot(ctx); err != nil {
258		log.Warn().Err(err).Msg("failed to reconcile apps at boot")
259	}
260	if err := rebuildAppHostIndex(ctx, svc); err != nil {
261		log.Warn().Err(err).Msg("failed to rebuild app host index at boot")
262	}
263	// Exactly one monitor per process: a second boot reconciliation must
264	// not orphan a running loop in the daemon context.
265	if svc.appMonitor == nil {
266		svc.appMonitor = newAppMonitor(svc.appDeploySvc, log)
267	}
268	svc.appMonitor.Start(ctx)
269}

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