v2.31.0-dev8 ec8c5b9

autoroute.go RawHistoryFolder

1package container
2
3import (
4	"bytes"
5	"context"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sort"
10	"strings"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/boundaries/in"
15	"github.com/bnema/gordon/internal/boundaries/out"
16	"github.com/bnema/gordon/internal/domain"
17	"github.com/bnema/gordon/internal/usecase/auto"
18)
19
20// EnvFileExtractor defines the interface for extracting env files from images.
21type EnvFileExtractor interface {
22	ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error)
23}
24
25// AutoRouteHandler handles image.pushed events for auto-route from labels.
26type AutoRouteHandler struct {
27	configSvc      in.ConfigService
28	containerSvc   in.ContainerService
29	blobStorage    out.BlobStorage
30	extractor      EnvFileExtractor
31	registryDomain string
32	envDir         string
33	ctx            context.Context
34}
35
36// NewAutoRouteHandler creates a new AutoRouteHandler.
37func NewAutoRouteHandler(
38	ctx context.Context,
39	configSvc in.ConfigService,
40	containerSvc in.ContainerService,
41	blobStorage out.BlobStorage,
42	registryDomain string,
43) *AutoRouteHandler {
44	return &AutoRouteHandler{
45		configSvc:      configSvc,
46		containerSvc:   containerSvc,
47		blobStorage:    blobStorage,
48		registryDomain: registryDomain,
49		ctx:            ctx,
50	}
51}
52
53// WithEnvExtractor sets the env file extractor for auto env-file copy feature.
54func (h *AutoRouteHandler) WithEnvExtractor(extractor EnvFileExtractor, envDir string) *AutoRouteHandler {
55	h.extractor = extractor
56	h.envDir = envDir
57	return h
58}
59
60// Handle handles an image.pushed event and creates routes from labels.
61func (h *AutoRouteHandler) Handle(ctx context.Context, event domain.Event) error {
62	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
63		zerowrap.FieldLayer:   "usecase",
64		zerowrap.FieldHandler: "AutoRouteHandler",
65		zerowrap.FieldEvent:   string(event.Type),
66		"event_id":            event.ID,
67	})
68	log := zerowrap.FromCtx(ctx)
69
70	if !h.configSvc.IsAutoRouteEnabled() {
71		log.Debug().Msg("auto-route disabled, skipping label extraction")
72		return nil
73	}
74
75	payload, ok := event.Data.(domain.ImagePushedPayload)
76	if !ok {
77		log.Warn().Msg("invalid event payload type")
78		return nil
79	}
80
81	if len(payload.Manifest) == 0 {
82		log.Debug().Msg("no manifest data in event, skipping")
83		return nil
84	}
85
86	log.Info().
87		Str("image", payload.Name).
88		Str("reference", payload.Reference).
89		Msg("processing image for auto-route labels")
90
91	labels, err := h.extractLabels(ctx, payload.Manifest)
92	if err != nil {
93		log.Debug().Err(err).Msg("failed to extract labels, skipping auto-route")
94		return nil
95	}
96
97	domains := h.collectDomains(labels)
98	if len(domains) == 0 {
99		log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping")
100		return nil
101	}
102
103	imageName := h.buildImageName(payload.Name, payload.Reference)
104	h.processRoutes(ctx, domains, imageName, labels)
105
106	return nil
107}
108
109// collectDomains collects all domains from labels.
110func (h *AutoRouteHandler) collectDomains(labels *domain.ImageLabels) []string {
111	var domains []string
112	if labels.Domain != "" {
113		domains = append(domains, labels.Domain)
114	}
115	domains = append(domains, labels.Domains...)
116	return domains
117}
118
119// buildImageName constructs the full image name with tag or digest.
120// For digest references (sha256:...), uses @ separator per Docker spec.
121// For tags, uses : separator.
122func (h *AutoRouteHandler) buildImageName(name, reference string) string {
123	if reference == "" {
124		return name
125	}
126	// Digest references use @ separator, tags use :
127	if strings.HasPrefix(reference, "sha256:") {
128		return fmt.Sprintf("%s@%s", name, reference)
129	}
130	return fmt.Sprintf("%s:%s", name, reference)
131}
132
133// buildFullImageRef constructs a fully qualified image reference with registry domain.
134// This is needed for Docker operations since images are stored with the registry prefix.
135func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
136	if h.registryDomain == "" {
137		return imageName
138	}
139
140	// Don't prefix if already has registry domain
141	if strings.HasPrefix(imageName, h.registryDomain+"/") {
142		return imageName
143	}
144
145	// Don't prefix if it's an external registry reference (contains dots and slashes)
146	repoPart := strings.Split(imageName, ":")[0]
147	if strings.Contains(repoPart, "@") {
148		repoPart = strings.Split(imageName, "@")[0]
149	}
150	if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
151		return imageName
152	}
153
154	return fmt.Sprintf("%s/%s", h.registryDomain, imageName)
155}
156
157// processRoutes processes each domain and creates/updates routes.
158func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
159	log := zerowrap.FromCtx(ctx)
160
161	// Load allowed domains once for all routes in this event.
162	allowedDomains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
163	if err != nil {
164		log.Warn().Err(err).Msg("failed to load auto-route allowed domains, skipping route processing")
165		return
166	}
167
168	// Build full image reference for env extraction (Docker needs fully qualified name)
169	fullImageRef := h.buildFullImageRef(imageName)
170
171	for _, routeDomain := range domains {
172		routeDomain = strings.TrimSpace(routeDomain)
173		if routeDomain == "" {
174			continue
175		}
176
177		created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178
179		if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
180			// Use full image ref for extraction (Docker knows image by this name)
181			if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
182				log.Warn().
183					Err(err).
184					Str("domain", routeDomain).
185					Str("env_file", labels.EnvFile).
186					Msg("failed to extract env file from image")
187			}
188		}
189	}
190}
191
192// createOrUpdateRoute creates a new route or updates an existing one.
193// It also triggers deployment for new routes since the ImagePushedHandler
194// may have already finished processing (handlers run concurrently).
195func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string, allowedDomains []string) bool {
196	log := zerowrap.FromCtx(ctx)
197
198	route := domain.Route{
199		Domain: routeDomain,
200		Image:  imageName,
201		HTTPS:  true,
202	}
203
204	existingRoutes := h.configSvc.GetRoutes(ctx)
205	for _, existing := range existingRoutes {
206		if existing.Domain == routeDomain {
207			route.HTTPS = existing.HTTPS
208			if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
209				log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210				return false
211			}
212			if existing.Image != imageName {
213				if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
214					log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
215				} else {
216					log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
217					// Trigger deploy for updated route
218					h.triggerDeploy(ctx, route)
219				}
220			}
221			return false
222		}
223	}
224
225	if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226		log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227		return false
228	}
229
230	if err := h.configSvc.AddRoute(ctx, route); err != nil {
231		log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
232	} else {
233		log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
234		// Trigger deploy for new route since ImagePushedHandler may have already finished
235		h.triggerDeploy(ctx, route)
236		return true
237	}
238
239	return false
240}
241
242// triggerDeploy initiates deployment for a route.
243func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
244	log := zerowrap.FromCtx(ctx)
245
246	if h.containerSvc == nil {
247		log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
248		return
249	}
250
251	// Mark context as internal deploy - the event originated from our own registry,
252	// so we can use internal registry (localhost) for image pulls.
253	internalCtx := domain.WithInternalDeploy(ctx)
254
255	if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
256		log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
257	} else {
258		log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
259	}
260}
261
262// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264	log := zerowrap.FromCtx(ctx)
265
266	log.Info().
267		Str("domain", routeDomain).
268		Str("image", imageRef).
269		Str("env_file", envFilePath).
270		Msg("extracting env file from image")
271
272	// Extract env file from image
273	envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
274	if err != nil {
275		return fmt.Errorf("failed to extract env file: %w", err)
276	}
277
278	if len(envData) == 0 {
279		log.Debug().Msg("env file is empty, skipping")
280		return nil
281	}
282
283	// Parse the extracted env file
284	imageEnv, err := domain.ParseEnvData(envData)
285	if err != nil {
286		return fmt.Errorf("failed to parse env file: %w", err)
287	}
288
289	if len(imageEnv) == 0 {
290		log.Debug().Msg("no environment variables found in env file")
291		return nil
292	}
293
294	// Security check: reject imported env files containing secret references
295	// to prevent attacker-controlled images from persisting ${provider:path}
296	// syntax that would later resolve against host secret providers.
297	for key, value := range imageEnv {
298		if domain.ContainsSecretReference(value) {
299			return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300		}
301	}
302
303	// Load existing env file for this domain
304	envFileName, err := domainToEnvFileName(routeDomain)
305	if err != nil {
306		return fmt.Errorf("invalid env storage domain: %w", err)
307	}
308	envFileDst := filepath.Join(h.envDir, envFileName)
309
310	existingEnv := make(map[string]string)
311	if data, err := os.ReadFile(envFileDst); err == nil {
312		var parseErr error
313		existingEnv, parseErr = domain.ParseEnvData(data)
314		if parseErr != nil {
315			return fmt.Errorf("failed to parse existing env file %q: %w", envFileDst, parseErr)
316		}
317	} else if !os.IsNotExist(err) {
318		return fmt.Errorf("failed to read existing env file %q: %w", envFileDst, err)
319	}
320
321	// Merge: image values are defaults, existing values win
322	merged := make(map[string]string)
323	for k, v := range imageEnv {
324		merged[k] = v
325	}
326	for k, v := range existingEnv {
327		merged[k] = v // Existing values override image values
328	}
329
330	// Write merged env file
331	if err := os.MkdirAll(h.envDir, 0700); err != nil {
332		return fmt.Errorf("failed to create env directory: %w", err)
333	}
334
335	if err := writeEnvFile(envFileDst, merged); err != nil {
336		return fmt.Errorf("failed to write env file: %w", err)
337	}
338
339	log.Info().
340		Str("domain", routeDomain).
341		Int("image_vars", len(imageEnv)).
342		Int("existing_vars", len(existingEnv)).
343		Int("merged_vars", len(merged)).
344		Msg("env file extracted and merged from image")
345
346	return nil
347}
348
349// CanHandle returns whether this handler can handle the given event type.
350func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
351	return eventType == domain.EventImagePushed
352}
353
354// extractLabels extracts Gordon labels from an image manifest.
355func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
356	log := zerowrap.FromCtx(ctx)
357
358	labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
359	if err != nil {
360		return nil, err
361	}
362
363	// Log the config digest for observability (best-effort parse for logging only)
364	if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
365		log.Debug().Str("config_digest", digest).Msg("found config digest")
366	}
367
368	return labels, nil
369}
370
371// domainToEnvFileName converts a domain to an env file name.
372// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
373func domainToEnvFileName(domainName string) (string, error) {
374	storageKey, err := domain.NewEnvStorageKey(domainName)
375	if err != nil {
376		return "", err
377	}
378
379	return storageKey.FileName(), nil
380}
381
382// writeEnvFile writes environment variables to a file.
383func writeEnvFile(path string, env map[string]string) error {
384	var buf bytes.Buffer
385
386	// Sort keys for consistent output
387	keys := make([]string, 0, len(env))
388	for k := range env {
389		keys = append(keys, k)
390	}
391	sort.Strings(keys)
392
393	// Write each key-value pair
394	for _, k := range keys {
395		v := env[k]
396		// Quote values that contain special characters
397		if strings.ContainsAny(v, " \t\n\"'$\\") {
398			v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
399		}
400		fmt.Fprintf(&buf, "%s=%s\n", k, v)
401	}
402
403	return os.WriteFile(path, buf.Bytes(), 0600)
404}

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