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