v3.0.0 f19f453

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

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