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 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178
179 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
180 // Use full image ref for extraction (Docker knows image by this name)
181 if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
182 log.Warn().
183 Err(err).
184 Str("domain", routeDomain).
185 Str("env_file", labels.EnvFile).
186 Msg("failed to extract env file from image")
187 }
188 }
189 }
190}
191
192// createOrUpdateRoute creates a new route or updates an existing one.
193// It also triggers deployment for new routes since the ImagePushedHandler
194// may have already finished processing (handlers run concurrently).
195func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string, allowedDomains []string) bool {
196 log := zerowrap.FromCtx(ctx)
197
198 route := domain.Route{
199 Domain: routeDomain,
200 Image: imageName,
201 HTTPS: true,
202 }
203
204 existingRoutes := h.configSvc.GetRoutes(ctx)
205 for _, existing := range existingRoutes {
206 if existing.Domain == routeDomain {
207 route.HTTPS = existing.HTTPS
208 if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
209 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210 return false
211 }
212 if existing.Image != imageName {
213 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
214 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
215 } else {
216 log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
217 // Trigger deploy for updated route
218 h.triggerDeploy(ctx, route)
219 }
220 }
221 return false
222 }
223 }
224
225 if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227 return false
228 }
229
230 if err := h.configSvc.AddRoute(ctx, route); err != nil {
231 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
232 } else {
233 log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
234 // Trigger deploy for new route since ImagePushedHandler may have already finished
235 h.triggerDeploy(ctx, route)
236 return true
237 }
238
239 return false
240}
241
242// triggerDeploy initiates deployment for a route.
243func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
244 log := zerowrap.FromCtx(ctx)
245
246 if h.containerSvc == nil {
247 log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
248 return
249 }
250
251 // Mark context as internal deploy - the event originated from our own registry,
252 // so we can use internal registry (localhost) for image pulls.
253 internalCtx := domain.WithInternalDeploy(ctx)
254
255 if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
256 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
257 } else {
258 log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
259 }
260}
261
262// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264 log := zerowrap.FromCtx(ctx)
265
266 log.Info().
267 Str("domain", routeDomain).
268 Str("image", imageRef).
269 Str("env_file", envFilePath).
270 Msg("extracting env file from image")
271
272 // Extract env file from image
273 envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
274 if err != nil {
275 return fmt.Errorf("failed to extract env file: %w", err)
276 }
277
278 if len(envData) == 0 {
279 log.Debug().Msg("env file is empty, skipping")
280 return nil
281 }
282
283 // Parse the extracted env file
284 imageEnv, err := domain.ParseEnvData(envData)
285 if err != nil {
286 return fmt.Errorf("failed to parse env file: %w", err)
287 }
288
289 if len(imageEnv) == 0 {
290 log.Debug().Msg("no environment variables found in env file")
291 return nil
292 }
293
294 // Security check: reject imported env files containing secret references
295 // to prevent attacker-controlled images from persisting ${provider:path}
296 // syntax that would later resolve against host secret providers.
297 for key, value := range imageEnv {
298 if domain.ContainsSecretReference(value) {
299 return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300 }
301 }
302
303 // Load existing env file for this domain
304 envFileName, err := domainToEnvFileName(routeDomain)
305 if err != nil {
306 return fmt.Errorf("invalid env storage domain: %w", err)
307 }
308 envFileDst := filepath.Join(h.envDir, envFileName)
309
310 existingEnv := make(map[string]string)
311 if data, err := os.ReadFile(envFileDst); err == nil {
312 var parseErr error
313 existingEnv, parseErr = domain.ParseEnvData(data)
314 if parseErr != nil {
315 return fmt.Errorf("failed to parse existing env file %q: %w", envFileDst, parseErr)
316 }
317 } else if !os.IsNotExist(err) {
318 return fmt.Errorf("failed to read existing env file %q: %w", envFileDst, err)
319 }
320
321 // Merge: image values are defaults, existing values win
322 merged := make(map[string]string)
323 for k, v := range imageEnv {
324 merged[k] = v
325 }
326 for k, v := range existingEnv {
327 merged[k] = v // Existing values override image values
328 }
329
330 // Write merged env file
331 if err := os.MkdirAll(h.envDir, 0700); err != nil {
332 return fmt.Errorf("failed to create env directory: %w", err)
333 }
334
335 if err := writeEnvFile(envFileDst, merged); err != nil {
336 return fmt.Errorf("failed to write env file: %w", err)
337 }
338
339 log.Info().
340 Str("domain", routeDomain).
341 Int("image_vars", len(imageEnv)).
342 Int("existing_vars", len(existingEnv)).
343 Int("merged_vars", len(merged)).
344 Msg("env file extracted and merged from image")
345
346 return nil
347}
348
349// CanHandle returns whether this handler can handle the given event type.
350func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
351 return eventType == domain.EventImagePushed
352}
353
354// extractLabels extracts Gordon labels from an image manifest.
355func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
356 log := zerowrap.FromCtx(ctx)
357
358 labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
359 if err != nil {
360 return nil, err
361 }
362
363 // Log the config digest for observability (best-effort parse for logging only)
364 if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
365 log.Debug().Str("config_digest", digest).Msg("found config digest")
366 }
367
368 return labels, nil
369}
370
371// domainToEnvFileName converts a domain to an env file name.
372// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
373func domainToEnvFileName(domainName string) (string, error) {
374 storageKey, err := domain.NewEnvStorageKey(domainName)
375 if err != nil {
376 return "", err
377 }
378
379 return storageKey.FileName(), nil
380}
381
382// writeEnvFile writes environment variables to a file.
383func writeEnvFile(path string, env map[string]string) error {
384 var buf bytes.Buffer
385
386 // Sort keys for consistent output
387 keys := make([]string, 0, len(env))
388 for k := range env {
389 keys = append(keys, k)
390 }
391 sort.Strings(keys)
392
393 // Write each key-value pair
394 for _, k := range keys {
395 v := env[k]
396 // Quote values that contain special characters
397 if strings.ContainsAny(v, " \t\n\"'$\\") {
398 v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
399 }
400 fmt.Fprintf(&buf, "%s=%s\n", k, v)
401 }
402
403 return os.WriteFile(path, buf.Bytes(), 0600)
404}