v2.31.0-dev12 6a38a35

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		canonicalDomain, ok := domain.CanonicalRouteDomain(routeDomain)
178		if !ok {
179			log.Warn().Str("domain", routeDomain).Msg("auto-route rejected invalid domain")
180			continue
181		}
182		routeDomain = canonicalDomain
183
184		created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
185
186		if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
187			// Use full image ref for extraction (Docker knows image by this name)
188			if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
189				log.Warn().
190					Err(err).
191					Str("domain", routeDomain).
192					Str("env_file", labels.EnvFile).
193					Msg("failed to extract env file from image")
194			}
195		}
196	}
197}
198
199// createOrUpdateRoute creates a new route or updates an existing one.
200// It also triggers deployment for new routes since the ImagePushedHandler
201// may have already finished processing (handlers run concurrently).
202func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string, allowedDomains []string) bool {
203	log := zerowrap.FromCtx(ctx)
204
205	route := domain.Route{
206		Domain: routeDomain,
207		Image:  imageName,
208		HTTPS:  true,
209	}
210
211	existingRoutes := h.configSvc.GetRoutes(ctx)
212	for _, existing := range existingRoutes {
213		if existing.Domain == routeDomain {
214			route.HTTPS = existing.HTTPS
215			if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
216				log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
217				return false
218			}
219			if existing.Image != imageName {
220				if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
221					log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
222				} else {
223					log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
224					// Trigger deploy for updated route
225					h.triggerDeploy(ctx, route)
226				}
227			}
228			return false
229		}
230	}
231
232	if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
233		log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
234		return false
235	}
236
237	if err := h.configSvc.AddRoute(ctx, route); err != nil {
238		log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
239	} else {
240		log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
241		// Trigger deploy for new route since ImagePushedHandler may have already finished
242		h.triggerDeploy(ctx, route)
243		return true
244	}
245
246	return false
247}
248
249// triggerDeploy initiates deployment for a route.
250func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
251	log := zerowrap.FromCtx(ctx)
252
253	if h.containerSvc == nil {
254		log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
255		return
256	}
257
258	// Mark context as internal deploy - the event originated from our own registry,
259	// so we can use internal registry (localhost) for image pulls.
260	internalCtx := domain.WithInternalDeploy(ctx)
261
262	if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
263		log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
264	} else {
265		log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
266	}
267}
268
269func allowedAbsoluteEnvFileRoots() []string {
270	return []string{"/app", "/workspace", "/usr/src/app"}
271}
272
273func validateAutoRouteEnvFilePath(envFilePath string) (string, error) {
274	envFilePath = strings.TrimSpace(envFilePath)
275	if envFilePath == "" {
276		return "", fmt.Errorf("env file path cannot be empty")
277	}
278	if strings.Contains(envFilePath, "\x00") {
279		return "", fmt.Errorf("env file path contains NUL byte")
280	}
281
282	cleaned := filepath.Clean(envFilePath)
283	if cleaned == "." || cleaned == string(filepath.Separator) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
284		return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
285	}
286	if filepath.IsAbs(cleaned) && !pathHasAllowedRoot(cleaned, allowedAbsoluteEnvFileRoots()) {
287		return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
288	}
289	return cleaned, nil
290}
291
292func pathHasAllowedRoot(path string, roots []string) bool {
293	for _, root := range roots {
294		if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
295			return true
296		}
297	}
298	return false
299}
300
301// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
302func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
303	log := zerowrap.FromCtx(ctx)
304
305	log.Info().
306		Str("domain", routeDomain).
307		Str("image", imageRef).
308		Str("env_file", envFilePath).
309		Msg("extracting env file from image")
310
311	safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath)
312	if err != nil {
313		return err
314	}
315
316	// Extract env file from image
317	envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath)
318	if err != nil {
319		return fmt.Errorf("failed to extract env file: %w", err)
320	}
321
322	if len(envData) == 0 {
323		log.Debug().Msg("env file is empty, skipping")
324		return nil
325	}
326
327	// Parse the extracted env file
328	imageEnv, err := domain.ParseEnvData(envData)
329	if err != nil {
330		return fmt.Errorf("failed to parse env file: %w", err)
331	}
332
333	if len(imageEnv) == 0 {
334		log.Debug().Msg("no environment variables found in env file")
335		return nil
336	}
337
338	if err := rejectSecretReferences(imageEnv); err != nil {
339		return err
340	}
341
342	// Load existing env file for this domain
343	envFileName, err := domainToEnvFileName(routeDomain)
344	if err != nil {
345		return fmt.Errorf("invalid env storage domain: %w", err)
346	}
347	envFileDst := filepath.Join(h.envDir, envFileName)
348
349	existingEnv := make(map[string]string)
350	if data, err := os.ReadFile(envFileDst); err == nil {
351		var parseErr error
352		existingEnv, parseErr = domain.ParseEnvData(data)
353		if parseErr != nil {
354			return fmt.Errorf("failed to parse existing env file %q: %w", envFileDst, parseErr)
355		}
356	} else if !os.IsNotExist(err) {
357		return fmt.Errorf("failed to read existing env file %q: %w", envFileDst, err)
358	}
359
360	// Merge: image values are defaults, existing values win
361	merged := make(map[string]string)
362	for k, v := range imageEnv {
363		merged[k] = v
364	}
365	for k, v := range existingEnv {
366		merged[k] = v // Existing values override image values
367	}
368
369	// Write merged env file
370	if err := os.MkdirAll(h.envDir, 0700); err != nil {
371		return fmt.Errorf("failed to create env directory: %w", err)
372	}
373
374	if err := writeEnvFile(envFileDst, merged); err != nil {
375		return fmt.Errorf("failed to write env file: %w", err)
376	}
377
378	log.Info().
379		Str("domain", routeDomain).
380		Int("image_vars", len(imageEnv)).
381		Int("existing_vars", len(existingEnv)).
382		Int("merged_vars", len(merged)).
383		Msg("env file extracted and merged from image")
384
385	return nil
386}
387
388// CanHandle returns whether this handler can handle the given event type.
389func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
390	return eventType == domain.EventImagePushed
391}
392
393// extractLabels extracts Gordon labels from an image manifest.
394func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
395	log := zerowrap.FromCtx(ctx)
396
397	labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
398	if err != nil {
399		return nil, err
400	}
401
402	// Log the config digest for observability (best-effort parse for logging only)
403	if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
404		log.Debug().Str("config_digest", digest).Msg("found config digest")
405	}
406
407	return labels, nil
408}
409
410// domainToEnvFileName converts a domain to an env file name.
411// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
412func domainToEnvFileName(domainName string) (string, error) {
413	storageKey, err := domain.NewEnvStorageKey(domainName)
414	if err != nil {
415		return "", err
416	}
417
418	return storageKey.FileName(), nil
419}
420
421func rejectSecretReferences(env map[string]string) error {
422	// Security check: reject imported env files containing secret references
423	// to prevent attacker-controlled images from persisting ${provider:path}
424	// syntax that would later resolve against host secret providers.
425	for key, value := range env {
426		if domain.ContainsSecretReference(value) {
427			return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
428		}
429	}
430	return nil
431}
432
433// writeEnvFile writes environment variables to a file.
434func writeEnvFile(path string, env map[string]string) error {
435	var buf bytes.Buffer
436
437	// Sort keys for consistent output
438	keys := make([]string, 0, len(env))
439	for k := range env {
440		keys = append(keys, k)
441	}
442	sort.Strings(keys)
443
444	// Write each key-value pair
445	for _, k := range keys {
446		v := env[k]
447		// Quote values that contain special characters
448		if strings.ContainsAny(v, " \t\n\"'$\\") {
449			v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
450		}
451		fmt.Fprintf(&buf, "%s=%s\n", k, v)
452	}
453
454	return os.WriteFile(path, buf.Bytes(), 0600)
455}

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