v2.30.0-pre4 15d9fe3

autoroute.go RawHistoryFolder

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

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