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}