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}