v3.3.1 31f1eec

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	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}

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