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(ctx context.Context, event domain.Event) error {
64 ctx = zerowrap.CtxWithFields(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}