v2.31.0-dev16 8d0a092

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

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