v2.8.0 6aced72

autoroute.go RawHistoryFolder

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

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