detached 917d4b7

feat(route): add auto-route handler from image labels

commit
917d4b7ff72f8af8496cc1dfc9eea107a0f62832
author
bnema · · 2026-01-16 20:51 UTC
parent
0d5a499
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +1299 -0
A internal/usecase/container/autoroute.go +472 -0
@@ -0,0 +1,472 @@
1+package container
2+
3+import (
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+ "gordon/internal/boundaries/in"
18+ "gordon/internal/boundaries/out"
19+ "gordon/internal/domain"
20+)
21+
22+// EnvFileExtractor defines the interface for extracting env files from images.
23+type 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.
28+type 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.
39+func 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.
56+func (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.
63+func (h *AutoRouteHandler) Handle(event domain.Event) error {
64+ ctx := zerowrap.CtxWithFields(h.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.
112+func (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.
124+func (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+// processRoutes processes each domain and creates/updates routes.
136+func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
137+ log := zerowrap.FromCtx(ctx)
138+
139+ for _, routeDomain := range domains {
140+ routeDomain = strings.TrimSpace(routeDomain)
141+ if routeDomain == "" {
142+ continue
143+ }
144+
145+ h.createOrUpdateRoute(ctx, routeDomain, imageName)
146+
147+ if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
148+ if err := h.extractAndMergeEnvFile(ctx, imageName, routeDomain, labels.EnvFile); err != nil {
149+ log.Warn().
150+ Err(err).
151+ Str("domain", routeDomain).
152+ Str("env_file", labels.EnvFile).
153+ Msg("failed to extract env file from image")
154+ }
155+ }
156+ }
157+}
158+
159+// createOrUpdateRoute creates a new route or updates an existing one.
160+// It also triggers deployment for new routes since the ImagePushedHandler
161+// may have already finished processing (handlers run concurrently).
162+func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) {
163+ log := zerowrap.FromCtx(ctx)
164+
165+ route := domain.Route{
166+ Domain: routeDomain,
167+ Image: imageName,
168+ }
169+
170+ existingRoutes := h.configSvc.GetRoutes(ctx)
171+ for _, existing := range existingRoutes {
172+ if existing.Domain == routeDomain {
173+ if existing.Image != imageName {
174+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
175+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
176+ } else {
177+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
178+ // Trigger deploy for updated route
179+ h.triggerDeploy(ctx, route)
180+ }
181+ }
182+ return
183+ }
184+ }
185+
186+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
187+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
188+ } else {
189+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
190+ // Trigger deploy for new route since ImagePushedHandler may have already finished
191+ h.triggerDeploy(ctx, route)
192+ }
193+}
194+
195+// triggerDeploy initiates deployment for a route.
196+func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
197+ log := zerowrap.FromCtx(ctx)
198+
199+ if h.containerSvc == nil {
200+ log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
201+ return
202+ }
203+
204+ // Mark context as internal deploy - the event originated from our own registry,
205+ // so we can use internal registry (localhost) for image pulls.
206+ internalCtx := domain.WithInternalDeploy(ctx)
207+
208+ if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
209+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
210+ } else {
211+ log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
212+ }
213+}
214+
215+// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
216+func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
217+ log := zerowrap.FromCtx(ctx)
218+
219+ log.Info().
220+ Str("domain", routeDomain).
221+ Str("image", imageRef).
222+ Str("env_file", envFilePath).
223+ Msg("extracting env file from image")
224+
225+ // Extract env file from image
226+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
227+ if err != nil {
228+ return fmt.Errorf("failed to extract env file: %w", err)
229+ }
230+
231+ if len(envData) == 0 {
232+ log.Debug().Msg("env file is empty, skipping")
233+ return nil
234+ }
235+
236+ // Parse the extracted env file
237+ imageEnv, err := parseEnvFile(envData)
238+ if err != nil {
239+ return fmt.Errorf("failed to parse env file: %w", err)
240+ }
241+
242+ if len(imageEnv) == 0 {
243+ log.Debug().Msg("no environment variables found in env file")
244+ return nil
245+ }
246+
247+ // Load existing env file for this domain
248+ envFileName := domainToEnvFileName(routeDomain)
249+ envFileDst := filepath.Join(h.envDir, envFileName)
250+
251+ existingEnv := make(map[string]string)
252+ if data, err := os.ReadFile(envFileDst); err == nil {
253+ existingEnv, _ = parseEnvFile(data)
254+ }
255+
256+ // Merge: image values are defaults, existing values win
257+ merged := make(map[string]string)
258+ for k, v := range imageEnv {
259+ merged[k] = v
260+ }
261+ for k, v := range existingEnv {
262+ merged[k] = v // Existing values override image values
263+ }
264+
265+ // Write merged env file
266+ if err := os.MkdirAll(h.envDir, 0700); err != nil {
267+ return fmt.Errorf("failed to create env directory: %w", err)
268+ }
269+
270+ if err := writeEnvFile(envFileDst, merged); err != nil {
271+ return fmt.Errorf("failed to write env file: %w", err)
272+ }
273+
274+ log.Info().
275+ Str("domain", routeDomain).
276+ Int("image_vars", len(imageEnv)).
277+ Int("existing_vars", len(existingEnv)).
278+ Int("merged_vars", len(merged)).
279+ Msg("env file extracted and merged from image")
280+
281+ return nil
282+}
283+
284+// CanHandle returns whether this handler can handle the given event type.
285+func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
286+ return eventType == domain.EventImagePushed
287+}
288+
289+// extractLabels extracts Gordon labels from an image manifest.
290+func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
291+ log := zerowrap.FromCtx(ctx)
292+
293+ // Parse manifest to get config digest
294+ configDigest, err := parseConfigDigest(manifestData)
295+ if err != nil {
296+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
297+ }
298+
299+ if configDigest == "" {
300+ return nil, fmt.Errorf("no config digest found in manifest")
301+ }
302+
303+ log.Debug().Str("config_digest", configDigest).Msg("found config digest")
304+
305+ // Read config blob
306+ reader, err := h.blobStorage.GetBlob(configDigest)
307+ if err != nil {
308+ return nil, fmt.Errorf("failed to get config blob: %w", err)
309+ }
310+ defer reader.Close()
311+
312+ configData, err := io.ReadAll(reader)
313+ if err != nil {
314+ return nil, fmt.Errorf("failed to read config blob: %w", err)
315+ }
316+
317+ // Parse config to extract labels
318+ labels, err := parseImageLabels(configData)
319+ if err != nil {
320+ return nil, fmt.Errorf("failed to parse config: %w", err)
321+ }
322+
323+ return labels, nil
324+}
325+
326+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
327+type manifestSchema struct {
328+ SchemaVersion int `json:"schemaVersion"`
329+ MediaType string `json:"mediaType"`
330+ Config struct {
331+ MediaType string `json:"mediaType"`
332+ Digest string `json:"digest"`
333+ Size int64 `json:"size"`
334+ } `json:"config"`
335+}
336+
337+// parseConfigDigest extracts the config digest from a manifest.
338+func parseConfigDigest(manifestData []byte) (string, error) {
339+ var manifest manifestSchema
340+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
341+ return "", err
342+ }
343+
344+ return manifest.Config.Digest, nil
345+}
346+
347+// imageConfig represents the relevant parts of an OCI/Docker image config.
348+type imageConfig struct {
349+ Config struct {
350+ Labels map[string]string `json:"Labels"`
351+ } `json:"config"`
352+}
353+
354+// parseImageLabels extracts Gordon labels from an image config.
355+func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
356+ var config imageConfig
357+ if err := json.Unmarshal(configData, &config); err != nil {
358+ return nil, err
359+ }
360+
361+ labels := &domain.ImageLabels{}
362+
363+ if config.Config.Labels == nil {
364+ return labels, nil
365+ }
366+
367+ // Extract gordon.* labels
368+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
369+ labels.Domain = strings.TrimSpace(v)
370+ }
371+
372+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
373+ // Parse comma-separated domains
374+ for _, d := range strings.Split(v, ",") {
375+ d = strings.TrimSpace(d)
376+ if d != "" {
377+ labels.Domains = append(labels.Domains, d)
378+ }
379+ }
380+ }
381+
382+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
383+ labels.Health = strings.TrimSpace(v)
384+ }
385+
386+ if v, ok := config.Config.Labels[domain.LabelPort]; ok {
387+ labels.Port = strings.TrimSpace(v)
388+ }
389+
390+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
391+ labels.EnvFile = strings.TrimSpace(v)
392+ }
393+
394+ return labels, nil
395+}
396+
397+// parseEnvFile parses an env file into a map of key-value pairs.
398+func parseEnvFile(data []byte) (map[string]string, error) {
399+ env := make(map[string]string)
400+
401+ scanner := bufio.NewScanner(bytes.NewReader(data))
402+ for scanner.Scan() {
403+ line := strings.TrimSpace(scanner.Text())
404+
405+ // Skip empty lines and comments
406+ if line == "" || strings.HasPrefix(line, "#") {
407+ continue
408+ }
409+
410+ // Parse KEY=value
411+ parts := strings.SplitN(line, "=", 2)
412+ if len(parts) != 2 {
413+ continue
414+ }
415+
416+ key := strings.TrimSpace(parts[0])
417+ value := strings.TrimSpace(parts[1])
418+
419+ // Remove surrounding quotes if present
420+ if len(value) >= 2 {
421+ if (value[0] == '"' && value[len(value)-1] == '"') ||
422+ (value[0] == '\'' && value[len(value)-1] == '\'') {
423+ value = value[1 : len(value)-1]
424+ }
425+ }
426+
427+ if key != "" {
428+ env[key] = value
429+ }
430+ }
431+
432+ if err := scanner.Err(); err != nil {
433+ return nil, err
434+ }
435+
436+ return env, nil
437+}
438+
439+// domainToEnvFileName converts a domain to an env file name.
440+// e.g., "app.mydomain.com" -> "app_mydomain_com.env"
441+// Must match the naming convention in envloader.FileLoader.getEnvFilePath
442+func domainToEnvFileName(domainName string) string {
443+ // Replace special characters with underscores (matches envloader)
444+ name := strings.ReplaceAll(domainName, ".", "_")
445+ name = strings.ReplaceAll(name, ":", "_")
446+ name = strings.ReplaceAll(name, "/", "_")
447+ return name + ".env"
448+}
449+
450+// writeEnvFile writes environment variables to a file.
451+func writeEnvFile(path string, env map[string]string) error {
452+ var buf bytes.Buffer
453+
454+ // Sort keys for consistent output
455+ keys := make([]string, 0, len(env))
456+ for k := range env {
457+ keys = append(keys, k)
458+ }
459+ sort.Strings(keys)
460+
461+ // Write each key-value pair
462+ for _, k := range keys {
463+ v := env[k]
464+ // Quote values that contain special characters
465+ if strings.ContainsAny(v, " \t\n\"'$\\") {
466+ v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
467+ }
468+ buf.WriteString(fmt.Sprintf("%s=%s\n", k, v))
469+ }
470+
471+ return os.WriteFile(path, buf.Bytes(), 0600)
472+}
@@ -0,0 +1,472 @@
1+package container
2+
3+import (
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+ "gordon/internal/boundaries/in"
18+ "gordon/internal/boundaries/out"
19+ "gordon/internal/domain"
20+)
21+
22+// EnvFileExtractor defines the interface for extracting env files from images.
23+type 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.
28+type 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.
39+func 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.
56+func (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.
63+func (h *AutoRouteHandler) Handle(event domain.Event) error {
64+ ctx := zerowrap.CtxWithFields(h.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.
112+func (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.
124+func (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+// processRoutes processes each domain and creates/updates routes.
136+func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
137+ log := zerowrap.FromCtx(ctx)
138+
139+ for _, routeDomain := range domains {
140+ routeDomain = strings.TrimSpace(routeDomain)
141+ if routeDomain == "" {
142+ continue
143+ }
144+
145+ h.createOrUpdateRoute(ctx, routeDomain, imageName)
146+
147+ if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
148+ if err := h.extractAndMergeEnvFile(ctx, imageName, routeDomain, labels.EnvFile); err != nil {
149+ log.Warn().
150+ Err(err).
151+ Str("domain", routeDomain).
152+ Str("env_file", labels.EnvFile).
153+ Msg("failed to extract env file from image")
154+ }
155+ }
156+ }
157+}
158+
159+// createOrUpdateRoute creates a new route or updates an existing one.
160+// It also triggers deployment for new routes since the ImagePushedHandler
161+// may have already finished processing (handlers run concurrently).
162+func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) {
163+ log := zerowrap.FromCtx(ctx)
164+
165+ route := domain.Route{
166+ Domain: routeDomain,
167+ Image: imageName,
168+ }
169+
170+ existingRoutes := h.configSvc.GetRoutes(ctx)
171+ for _, existing := range existingRoutes {
172+ if existing.Domain == routeDomain {
173+ if existing.Image != imageName {
174+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
175+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
176+ } else {
177+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
178+ // Trigger deploy for updated route
179+ h.triggerDeploy(ctx, route)
180+ }
181+ }
182+ return
183+ }
184+ }
185+
186+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
187+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
188+ } else {
189+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
190+ // Trigger deploy for new route since ImagePushedHandler may have already finished
191+ h.triggerDeploy(ctx, route)
192+ }
193+}
194+
195+// triggerDeploy initiates deployment for a route.
196+func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
197+ log := zerowrap.FromCtx(ctx)
198+
199+ if h.containerSvc == nil {
200+ log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
201+ return
202+ }
203+
204+ // Mark context as internal deploy - the event originated from our own registry,
205+ // so we can use internal registry (localhost) for image pulls.
206+ internalCtx := domain.WithInternalDeploy(ctx)
207+
208+ if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
209+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
210+ } else {
211+ log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
212+ }
213+}
214+
215+// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
216+func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
217+ log := zerowrap.FromCtx(ctx)
218+
219+ log.Info().
220+ Str("domain", routeDomain).
221+ Str("image", imageRef).
222+ Str("env_file", envFilePath).
223+ Msg("extracting env file from image")
224+
225+ // Extract env file from image
226+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
227+ if err != nil {
228+ return fmt.Errorf("failed to extract env file: %w", err)
229+ }
230+
231+ if len(envData) == 0 {
232+ log.Debug().Msg("env file is empty, skipping")
233+ return nil
234+ }
235+
236+ // Parse the extracted env file
237+ imageEnv, err := parseEnvFile(envData)
238+ if err != nil {
239+ return fmt.Errorf("failed to parse env file: %w", err)
240+ }
241+
242+ if len(imageEnv) == 0 {
243+ log.Debug().Msg("no environment variables found in env file")
244+ return nil
245+ }
246+
247+ // Load existing env file for this domain
248+ envFileName := domainToEnvFileName(routeDomain)
249+ envFileDst := filepath.Join(h.envDir, envFileName)
250+
251+ existingEnv := make(map[string]string)
252+ if data, err := os.ReadFile(envFileDst); err == nil {
253+ existingEnv, _ = parseEnvFile(data)
254+ }
255+
256+ // Merge: image values are defaults, existing values win
257+ merged := make(map[string]string)
258+ for k, v := range imageEnv {
259+ merged[k] = v
260+ }
261+ for k, v := range existingEnv {
262+ merged[k] = v // Existing values override image values
263+ }
264+
265+ // Write merged env file
266+ if err := os.MkdirAll(h.envDir, 0700); err != nil {
267+ return fmt.Errorf("failed to create env directory: %w", err)
268+ }
269+
270+ if err := writeEnvFile(envFileDst, merged); err != nil {
271+ return fmt.Errorf("failed to write env file: %w", err)
272+ }
273+
274+ log.Info().
275+ Str("domain", routeDomain).
276+ Int("image_vars", len(imageEnv)).
277+ Int("existing_vars", len(existingEnv)).
278+ Int("merged_vars", len(merged)).
279+ Msg("env file extracted and merged from image")
280+
281+ return nil
282+}
283+
284+// CanHandle returns whether this handler can handle the given event type.
285+func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
286+ return eventType == domain.EventImagePushed
287+}
288+
289+// extractLabels extracts Gordon labels from an image manifest.
290+func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
291+ log := zerowrap.FromCtx(ctx)
292+
293+ // Parse manifest to get config digest
294+ configDigest, err := parseConfigDigest(manifestData)
295+ if err != nil {
296+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
297+ }
298+
299+ if configDigest == "" {
300+ return nil, fmt.Errorf("no config digest found in manifest")
301+ }
302+
303+ log.Debug().Str("config_digest", configDigest).Msg("found config digest")
304+
305+ // Read config blob
306+ reader, err := h.blobStorage.GetBlob(configDigest)
307+ if err != nil {
308+ return nil, fmt.Errorf("failed to get config blob: %w", err)
309+ }
310+ defer reader.Close()
311+
312+ configData, err := io.ReadAll(reader)
313+ if err != nil {
314+ return nil, fmt.Errorf("failed to read config blob: %w", err)
315+ }
316+
317+ // Parse config to extract labels
318+ labels, err := parseImageLabels(configData)
319+ if err != nil {
320+ return nil, fmt.Errorf("failed to parse config: %w", err)
321+ }
322+
323+ return labels, nil
324+}
325+
326+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
327+type manifestSchema struct {
328+ SchemaVersion int `json:"schemaVersion"`
329+ MediaType string `json:"mediaType"`
330+ Config struct {
331+ MediaType string `json:"mediaType"`
332+ Digest string `json:"digest"`
333+ Size int64 `json:"size"`
334+ } `json:"config"`
335+}
336+
337+// parseConfigDigest extracts the config digest from a manifest.
338+func parseConfigDigest(manifestData []byte) (string, error) {
339+ var manifest manifestSchema
340+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
341+ return "", err
342+ }
343+
344+ return manifest.Config.Digest, nil
345+}
346+
347+// imageConfig represents the relevant parts of an OCI/Docker image config.
348+type imageConfig struct {
349+ Config struct {
350+ Labels map[string]string `json:"Labels"`
351+ } `json:"config"`
352+}
353+
354+// parseImageLabels extracts Gordon labels from an image config.
355+func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
356+ var config imageConfig
357+ if err := json.Unmarshal(configData, &config); err != nil {
358+ return nil, err
359+ }
360+
361+ labels := &domain.ImageLabels{}
362+
363+ if config.Config.Labels == nil {
364+ return labels, nil
365+ }
366+
367+ // Extract gordon.* labels
368+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
369+ labels.Domain = strings.TrimSpace(v)
370+ }
371+
372+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
373+ // Parse comma-separated domains
374+ for _, d := range strings.Split(v, ",") {
375+ d = strings.TrimSpace(d)
376+ if d != "" {
377+ labels.Domains = append(labels.Domains, d)
378+ }
379+ }
380+ }
381+
382+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
383+ labels.Health = strings.TrimSpace(v)
384+ }
385+
386+ if v, ok := config.Config.Labels[domain.LabelPort]; ok {
387+ labels.Port = strings.TrimSpace(v)
388+ }
389+
390+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
391+ labels.EnvFile = strings.TrimSpace(v)
392+ }
393+
394+ return labels, nil
395+}
396+
397+// parseEnvFile parses an env file into a map of key-value pairs.
398+func parseEnvFile(data []byte) (map[string]string, error) {
399+ env := make(map[string]string)
400+
401+ scanner := bufio.NewScanner(bytes.NewReader(data))
402+ for scanner.Scan() {
403+ line := strings.TrimSpace(scanner.Text())
404+
405+ // Skip empty lines and comments
406+ if line == "" || strings.HasPrefix(line, "#") {
407+ continue
408+ }
409+
410+ // Parse KEY=value
411+ parts := strings.SplitN(line, "=", 2)
412+ if len(parts) != 2 {
413+ continue
414+ }
415+
416+ key := strings.TrimSpace(parts[0])
417+ value := strings.TrimSpace(parts[1])
418+
419+ // Remove surrounding quotes if present
420+ if len(value) >= 2 {
421+ if (value[0] == '"' && value[len(value)-1] == '"') ||
422+ (value[0] == '\'' && value[len(value)-1] == '\'') {
423+ value = value[1 : len(value)-1]
424+ }
425+ }
426+
427+ if key != "" {
428+ env[key] = value
429+ }
430+ }
431+
432+ if err := scanner.Err(); err != nil {
433+ return nil, err
434+ }
435+
436+ return env, nil
437+}
438+
439+// domainToEnvFileName converts a domain to an env file name.
440+// e.g., "app.mydomain.com" -> "app_mydomain_com.env"
441+// Must match the naming convention in envloader.FileLoader.getEnvFilePath
442+func domainToEnvFileName(domainName string) string {
443+ // Replace special characters with underscores (matches envloader)
444+ name := strings.ReplaceAll(domainName, ".", "_")
445+ name = strings.ReplaceAll(name, ":", "_")
446+ name = strings.ReplaceAll(name, "/", "_")
447+ return name + ".env"
448+}
449+
450+// writeEnvFile writes environment variables to a file.
451+func writeEnvFile(path string, env map[string]string) error {
452+ var buf bytes.Buffer
453+
454+ // Sort keys for consistent output
455+ keys := make([]string, 0, len(env))
456+ for k := range env {
457+ keys = append(keys, k)
458+ }
459+ sort.Strings(keys)
460+
461+ // Write each key-value pair
462+ for _, k := range keys {
463+ v := env[k]
464+ // Quote values that contain special characters
465+ if strings.ContainsAny(v, " \t\n\"'$\\") {
466+ v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
467+ }
468+ buf.WriteString(fmt.Sprintf("%s=%s\n", k, v))
469+ }
470+
471+ return os.WriteFile(path, buf.Bytes(), 0600)
472+}
A internal/usecase/container/autoroute_test.go +827 -0
@@ -0,0 +1,827 @@
1+package container
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ inmocks "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/boundaries/out/mocks"
17+ "gordon/internal/domain"
18+)
19+
20+// domainToEnvFileName tests
21+
22+func TestDomainToEnvFileName(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ domain string
26+ expected string
27+ }{
28+ {
29+ name: "simple domain",
30+ domain: "app.example.com",
31+ expected: "app_example_com.env",
32+ },
33+ {
34+ name: "domain with port",
35+ domain: "app.example.com:8080",
36+ expected: "app_example_com_8080.env",
37+ },
38+ {
39+ name: "domain with path",
40+ domain: "app.example.com/api",
41+ expected: "app_example_com_api.env",
42+ },
43+ {
44+ name: "complex domain",
45+ domain: "sub.app.example.com:443/v1",
46+ expected: "sub_app_example_com_443_v1.env",
47+ },
48+ {
49+ name: "single word",
50+ domain: "localhost",
51+ expected: "localhost.env",
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := domainToEnvFileName(tt.domain)
58+ assert.Equal(t, tt.expected, result)
59+ })
60+ }
61+}
62+
63+// parseEnvFile tests
64+
65+func TestParseEnvFile(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ input string
69+ expected map[string]string
70+ wantErr bool
71+ }{
72+ {
73+ name: "simple key-value pairs",
74+ input: "FOO=bar\nBAZ=qux",
75+ expected: map[string]string{
76+ "FOO": "bar",
77+ "BAZ": "qux",
78+ },
79+ },
80+ {
81+ name: "with comments and empty lines",
82+ input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
83+ expected: map[string]string{
84+ "FOO": "bar",
85+ "BAZ": "qux",
86+ },
87+ },
88+ {
89+ name: "with quoted values - double quotes",
90+ input: `FOO="bar baz"`,
91+ expected: map[string]string{
92+ "FOO": "bar baz",
93+ },
94+ },
95+ {
96+ name: "with quoted values - single quotes",
97+ input: `FOO='bar baz'`,
98+ expected: map[string]string{
99+ "FOO": "bar baz",
100+ },
101+ },
102+ {
103+ name: "value with equals sign",
104+ input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
105+ expected: map[string]string{
106+ "DATABASE_URL": "postgres://user:pass@host/db?option=value",
107+ },
108+ },
109+ {
110+ name: "empty value",
111+ input: "EMPTY=",
112+ expected: map[string]string{
113+ "EMPTY": "",
114+ },
115+ },
116+ {
117+ name: "empty file",
118+ input: "",
119+ expected: map[string]string{},
120+ },
121+ {
122+ name: "only comments",
123+ input: "# comment 1\n# comment 2",
124+ expected: map[string]string{},
125+ },
126+ {
127+ name: "malformed line without equals",
128+ input: "MALFORMED\nGOOD=value",
129+ expected: map[string]string{"GOOD": "value"},
130+ },
131+ {
132+ name: "whitespace trimming",
133+ input: " FOO = bar \n BAZ=qux",
134+ expected: map[string]string{
135+ "FOO": "bar",
136+ "BAZ": "qux",
137+ },
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ result, err := parseEnvFile([]byte(tt.input))
144+
145+ if tt.wantErr {
146+ assert.Error(t, err)
147+ return
148+ }
149+
150+ require.NoError(t, err)
151+ assert.Equal(t, tt.expected, result)
152+ })
153+ }
154+}
155+
156+// parseConfigDigest tests
157+
158+func TestParseConfigDigest(t *testing.T) {
159+ tests := []struct {
160+ name string
161+ manifest map[string]any
162+ expected string
163+ wantErr bool
164+ }{
165+ {
166+ name: "valid manifest v2",
167+ manifest: map[string]any{
168+ "schemaVersion": 2,
169+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
170+ "config": map[string]any{
171+ "mediaType": "application/vnd.docker.container.image.v1+json",
172+ "digest": "sha256:abc123",
173+ "size": 1234,
174+ },
175+ },
176+ expected: "sha256:abc123",
177+ },
178+ {
179+ name: "OCI manifest",
180+ manifest: map[string]any{
181+ "schemaVersion": 2,
182+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
183+ "config": map[string]any{
184+ "mediaType": "application/vnd.oci.image.config.v1+json",
185+ "digest": "sha256:def456",
186+ "size": 5678,
187+ },
188+ },
189+ expected: "sha256:def456",
190+ },
191+ {
192+ name: "empty config digest",
193+ manifest: map[string]any{
194+ "schemaVersion": 2,
195+ "config": map[string]any{
196+ "digest": "",
197+ },
198+ },
199+ expected: "",
200+ },
201+ }
202+
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ data, err := json.Marshal(tt.manifest)
206+ require.NoError(t, err)
207+
208+ result, err := parseConfigDigest(data)
209+
210+ if tt.wantErr {
211+ assert.Error(t, err)
212+ return
213+ }
214+
215+ require.NoError(t, err)
216+ assert.Equal(t, tt.expected, result)
217+ })
218+ }
219+}
220+
221+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
222+ _, err := parseConfigDigest([]byte("invalid json"))
223+ assert.Error(t, err)
224+}
225+
226+// parseImageLabels tests
227+
228+func TestParseImageLabels(t *testing.T) {
229+ tests := []struct {
230+ name string
231+ config map[string]any
232+ expected *domain.ImageLabels
233+ }{
234+ {
235+ name: "single domain label",
236+ config: map[string]any{
237+ "config": map[string]any{
238+ "Labels": map[string]any{
239+ "gordon.domain": "app.example.com",
240+ },
241+ },
242+ },
243+ expected: &domain.ImageLabels{
244+ Domain: "app.example.com",
245+ },
246+ },
247+ {
248+ name: "multiple domains label",
249+ config: map[string]any{
250+ "config": map[string]any{
251+ "Labels": map[string]any{
252+ "gordon.domains": "app1.example.com, app2.example.com",
253+ },
254+ },
255+ },
256+ expected: &domain.ImageLabels{
257+ Domains: []string{"app1.example.com", "app2.example.com"},
258+ },
259+ },
260+ {
261+ name: "all gordon labels",
262+ config: map[string]any{
263+ "config": map[string]any{
264+ "Labels": map[string]any{
265+ "gordon.domain": "app.example.com",
266+ "gordon.health": "/healthz",
267+ "gordon.port": "8080",
268+ "gordon.env-file": ".env.prod",
269+ },
270+ },
271+ },
272+ expected: &domain.ImageLabels{
273+ Domain: "app.example.com",
274+ Health: "/healthz",
275+ Port: "8080",
276+ EnvFile: ".env.prod",
277+ },
278+ },
279+ {
280+ name: "no labels",
281+ config: map[string]any{
282+ "config": map[string]any{
283+ "Labels": nil,
284+ },
285+ },
286+ expected: &domain.ImageLabels{},
287+ },
288+ {
289+ name: "no gordon labels",
290+ config: map[string]any{
291+ "config": map[string]any{
292+ "Labels": map[string]any{
293+ "other.label": "value",
294+ },
295+ },
296+ },
297+ expected: &domain.ImageLabels{},
298+ },
299+ {
300+ name: "whitespace in labels",
301+ config: map[string]any{
302+ "config": map[string]any{
303+ "Labels": map[string]any{
304+ "gordon.domain": " app.example.com ",
305+ },
306+ },
307+ },
308+ expected: &domain.ImageLabels{
309+ Domain: "app.example.com",
310+ },
311+ },
312+ }
313+
314+ for _, tt := range tests {
315+ t.Run(tt.name, func(t *testing.T) {
316+ data, err := json.Marshal(tt.config)
317+ require.NoError(t, err)
318+
319+ result, err := parseImageLabels(data)
320+ require.NoError(t, err)
321+
322+ assert.Equal(t, tt.expected.Domain, result.Domain)
323+ assert.Equal(t, tt.expected.Domains, result.Domains)
324+ assert.Equal(t, tt.expected.Health, result.Health)
325+ assert.Equal(t, tt.expected.Port, result.Port)
326+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
327+ })
328+ }
329+}
330+
331+func TestParseImageLabels_InvalidJSON(t *testing.T) {
332+ _, err := parseImageLabels([]byte("invalid json"))
333+ assert.Error(t, err)
334+}
335+
336+// AutoRouteHandler tests
337+
338+func TestAutoRouteHandler_CanHandle(t *testing.T) {
339+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
340+ configSvc := inmocks.NewMockConfigService(t)
341+ containerSvc := inmocks.NewMockContainerService(t)
342+ blobStorage := mocks.NewMockBlobStorage(t)
343+
344+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
345+
346+ assert.True(t, handler.CanHandle(domain.EventImagePushed))
347+ assert.False(t, handler.CanHandle(domain.EventConfigReload))
348+ assert.False(t, handler.CanHandle(domain.EventManualReload))
349+}
350+
351+func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
352+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
353+ configSvc := inmocks.NewMockConfigService(t)
354+ containerSvc := inmocks.NewMockContainerService(t)
355+ blobStorage := mocks.NewMockBlobStorage(t)
356+
357+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
358+
359+ configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
360+
361+ event := domain.Event{
362+ ID: "event-123",
363+ Type: domain.EventImagePushed,
364+ Data: domain.ImagePushedPayload{
365+ Name: "myapp",
366+ Reference: "latest",
367+ Manifest: []byte(`{"schemaVersion": 2}`),
368+ },
369+ }
370+
371+ err := handler.Handle(event)
372+
373+ assert.NoError(t, err)
374+}
375+
376+func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
377+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
378+ configSvc := inmocks.NewMockConfigService(t)
379+ containerSvc := inmocks.NewMockContainerService(t)
380+ blobStorage := mocks.NewMockBlobStorage(t)
381+
382+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
383+
384+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
385+
386+ event := domain.Event{
387+ ID: "event-123",
388+ Type: domain.EventImagePushed,
389+ Data: "invalid payload type",
390+ }
391+
392+ err := handler.Handle(event)
393+
394+ // Handler skips invalid payload, doesn't error
395+ assert.NoError(t, err)
396+}
397+
398+func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
399+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
400+ configSvc := inmocks.NewMockConfigService(t)
401+ containerSvc := inmocks.NewMockContainerService(t)
402+ blobStorage := mocks.NewMockBlobStorage(t)
403+
404+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
405+
406+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
407+
408+ event := domain.Event{
409+ ID: "event-123",
410+ Type: domain.EventImagePushed,
411+ Data: domain.ImagePushedPayload{
412+ Name: "myapp",
413+ Reference: "latest",
414+ Manifest: []byte{},
415+ },
416+ }
417+
418+ err := handler.Handle(event)
419+
420+ assert.NoError(t, err)
421+}
422+
423+func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
424+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
425+ configSvc := inmocks.NewMockConfigService(t)
426+ containerSvc := inmocks.NewMockContainerService(t)
427+ blobStorage := mocks.NewMockBlobStorage(t)
428+
429+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
430+
431+ // Build manifest with config digest
432+ manifest := map[string]any{
433+ "schemaVersion": 2,
434+ "config": map[string]any{
435+ "digest": "sha256:configdigest123",
436+ },
437+ }
438+ manifestData, _ := json.Marshal(manifest)
439+
440+ // Build image config with gordon labels
441+ imageConfig := map[string]any{
442+ "config": map[string]any{
443+ "Labels": map[string]any{
444+ "gordon.domain": "app.example.com",
445+ },
446+ },
447+ }
448+ configData, _ := json.Marshal(imageConfig)
449+
450+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
451+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
452+
453+ // No existing routes
454+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
455+
456+ // Expect route to be added
457+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
458+ Domain: "app.example.com",
459+ Image: "myapp:latest",
460+ }).Return(nil)
461+
462+ // Expect deploy to be triggered
463+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
464+ Domain: "app.example.com",
465+ Image: "myapp:latest",
466+ }).Return(&domain.Container{ID: "container-1"}, nil)
467+
468+ event := domain.Event{
469+ ID: "event-123",
470+ Type: domain.EventImagePushed,
471+ Data: domain.ImagePushedPayload{
472+ Name: "myapp",
473+ Reference: "latest",
474+ Manifest: manifestData,
475+ },
476+ }
477+
478+ err := handler.Handle(event)
479+
480+ assert.NoError(t, err)
481+}
482+
483+func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
484+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
485+ configSvc := inmocks.NewMockConfigService(t)
486+ containerSvc := inmocks.NewMockContainerService(t)
487+ blobStorage := mocks.NewMockBlobStorage(t)
488+
489+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
490+
491+ // Build manifest with config digest
492+ manifest := map[string]any{
493+ "schemaVersion": 2,
494+ "config": map[string]any{
495+ "digest": "sha256:configdigest123",
496+ },
497+ }
498+ manifestData, _ := json.Marshal(manifest)
499+
500+ // Build image config with gordon labels
501+ imageConfig := map[string]any{
502+ "config": map[string]any{
503+ "Labels": map[string]any{
504+ "gordon.domain": "app.example.com",
505+ },
506+ },
507+ }
508+ configData, _ := json.Marshal(imageConfig)
509+
510+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
511+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
512+
513+ // Existing route with different image
514+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
515+ {Domain: "app.example.com", Image: "myapp:v1"},
516+ })
517+
518+ // Expect route to be updated
519+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
520+ Domain: "app.example.com",
521+ Image: "myapp:v2",
522+ }).Return(nil)
523+
524+ // Expect deploy to be triggered
525+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
526+ Domain: "app.example.com",
527+ Image: "myapp:v2",
528+ }).Return(&domain.Container{ID: "container-1"}, nil)
529+
530+ event := domain.Event{
531+ ID: "event-123",
532+ Type: domain.EventImagePushed,
533+ Data: domain.ImagePushedPayload{
534+ Name: "myapp",
535+ Reference: "v2",
536+ Manifest: manifestData,
537+ },
538+ }
539+
540+ err := handler.Handle(event)
541+
542+ assert.NoError(t, err)
543+}
544+
545+func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
546+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
547+ configSvc := inmocks.NewMockConfigService(t)
548+ containerSvc := inmocks.NewMockContainerService(t)
549+ blobStorage := mocks.NewMockBlobStorage(t)
550+
551+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
552+
553+ // Build manifest with config digest
554+ manifest := map[string]any{
555+ "schemaVersion": 2,
556+ "config": map[string]any{
557+ "digest": "sha256:configdigest123",
558+ },
559+ }
560+ manifestData, _ := json.Marshal(manifest)
561+
562+ // Build image config with gordon labels
563+ imageConfig := map[string]any{
564+ "config": map[string]any{
565+ "Labels": map[string]any{
566+ "gordon.domain": "app.example.com",
567+ },
568+ },
569+ }
570+ configData, _ := json.Marshal(imageConfig)
571+
572+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
573+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
574+
575+ // Existing route with same image - no update needed
576+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
577+ {Domain: "app.example.com", Image: "myapp:latest"},
578+ })
579+
580+ // No AddRoute, UpdateRoute, or Deploy calls expected
581+
582+ event := domain.Event{
583+ ID: "event-123",
584+ Type: domain.EventImagePushed,
585+ Data: domain.ImagePushedPayload{
586+ Name: "myapp",
587+ Reference: "latest",
588+ Manifest: manifestData,
589+ },
590+ }
591+
592+ err := handler.Handle(event)
593+
594+ assert.NoError(t, err)
595+}
596+
597+func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
598+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
599+ configSvc := inmocks.NewMockConfigService(t)
600+ containerSvc := inmocks.NewMockContainerService(t)
601+ blobStorage := mocks.NewMockBlobStorage(t)
602+
603+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
604+
605+ // Build manifest with config digest
606+ manifest := map[string]any{
607+ "schemaVersion": 2,
608+ "config": map[string]any{
609+ "digest": "sha256:configdigest123",
610+ },
611+ }
612+ manifestData, _ := json.Marshal(manifest)
613+
614+ // Build image config with no gordon labels
615+ imageConfig := map[string]any{
616+ "config": map[string]any{
617+ "Labels": map[string]any{
618+ "other.label": "value",
619+ },
620+ },
621+ }
622+ configData, _ := json.Marshal(imageConfig)
623+
624+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
625+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
626+
627+ // No route operations expected
628+
629+ event := domain.Event{
630+ ID: "event-123",
631+ Type: domain.EventImagePushed,
632+ Data: domain.ImagePushedPayload{
633+ Name: "myapp",
634+ Reference: "latest",
635+ Manifest: manifestData,
636+ },
637+ }
638+
639+ err := handler.Handle(event)
640+
641+ assert.NoError(t, err)
642+}
643+
644+func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
645+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
646+ configSvc := inmocks.NewMockConfigService(t)
647+ containerSvc := inmocks.NewMockContainerService(t)
648+ blobStorage := mocks.NewMockBlobStorage(t)
649+
650+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
651+
652+ // Build manifest with config digest
653+ manifest := map[string]any{
654+ "schemaVersion": 2,
655+ "config": map[string]any{
656+ "digest": "sha256:configdigest123",
657+ },
658+ }
659+ manifestData, _ := json.Marshal(manifest)
660+
661+ // Build image config with multiple domains
662+ imageConfig := map[string]any{
663+ "config": map[string]any{
664+ "Labels": map[string]any{
665+ "gordon.domain": "app.example.com",
666+ "gordon.domains": "api.example.com, www.example.com",
667+ },
668+ },
669+ }
670+ configData, _ := json.Marshal(imageConfig)
671+
672+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
673+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
674+
675+ // No existing routes
676+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
677+
678+ // Expect routes to be added for all three domains
679+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
680+ Domain: "app.example.com",
681+ Image: "myapp:latest",
682+ }).Return(nil)
683+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
684+ Domain: "api.example.com",
685+ Image: "myapp:latest",
686+ }).Return(nil)
687+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
688+ Domain: "www.example.com",
689+ Image: "myapp:latest",
690+ }).Return(nil)
691+
692+ // Expect deploy for all three
693+ containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
694+
695+ event := domain.Event{
696+ ID: "event-123",
697+ Type: domain.EventImagePushed,
698+ Data: domain.ImagePushedPayload{
699+ Name: "myapp",
700+ Reference: "latest",
701+ Manifest: manifestData,
702+ },
703+ }
704+
705+ err := handler.Handle(event)
706+
707+ assert.NoError(t, err)
708+}
709+
710+func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
711+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
712+ configSvc := inmocks.NewMockConfigService(t)
713+ blobStorage := mocks.NewMockBlobStorage(t)
714+
715+ // Create handler without container service
716+ handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
717+
718+ route := domain.Route{
719+ Domain: "app.example.com",
720+ Image: "myapp:latest",
721+ }
722+
723+ // Should not panic, just log and skip
724+ handler.triggerDeploy(ctx, route)
725+}
726+
727+// collectDomains tests
728+
729+func TestAutoRouteHandler_CollectDomains(t *testing.T) {
730+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
731+ configSvc := inmocks.NewMockConfigService(t)
732+ containerSvc := inmocks.NewMockContainerService(t)
733+ blobStorage := mocks.NewMockBlobStorage(t)
734+
735+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
736+
737+ tests := []struct {
738+ name string
739+ labels *domain.ImageLabels
740+ expected []string
741+ }{
742+ {
743+ name: "single domain",
744+ labels: &domain.ImageLabels{
745+ Domain: "app.example.com",
746+ },
747+ expected: []string{"app.example.com"},
748+ },
749+ {
750+ name: "domains list only",
751+ labels: &domain.ImageLabels{
752+ Domains: []string{"api.example.com", "www.example.com"},
753+ },
754+ expected: []string{"api.example.com", "www.example.com"},
755+ },
756+ {
757+ name: "both domain and domains",
758+ labels: &domain.ImageLabels{
759+ Domain: "app.example.com",
760+ Domains: []string{"api.example.com"},
761+ },
762+ expected: []string{"app.example.com", "api.example.com"},
763+ },
764+ {
765+ name: "empty labels",
766+ labels: &domain.ImageLabels{},
767+ expected: nil,
768+ },
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.name, func(t *testing.T) {
773+ result := handler.collectDomains(tt.labels)
774+ assert.Equal(t, tt.expected, result)
775+ })
776+ }
777+}
778+
779+// buildImageName tests
780+
781+func TestAutoRouteHandler_BuildImageName(t *testing.T) {
782+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
783+ configSvc := inmocks.NewMockConfigService(t)
784+ containerSvc := inmocks.NewMockContainerService(t)
785+ blobStorage := mocks.NewMockBlobStorage(t)
786+
787+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
788+
789+ tests := []struct {
790+ name string
791+ imageName string
792+ reference string
793+ expected string
794+ }{
795+ {
796+ name: "with tag",
797+ imageName: "myapp",
798+ reference: "latest",
799+ expected: "myapp:latest",
800+ },
801+ {
802+ name: "with version tag",
803+ imageName: "myapp",
804+ reference: "v1.2.3",
805+ expected: "myapp:v1.2.3",
806+ },
807+ {
808+ name: "empty reference",
809+ imageName: "myapp",
810+ reference: "",
811+ expected: "myapp",
812+ },
813+ {
814+ name: "with digest",
815+ imageName: "myapp",
816+ reference: "sha256:abc123",
817+ expected: "myapp:sha256:abc123",
818+ },
819+ }
820+
821+ for _, tt := range tests {
822+ t.Run(tt.name, func(t *testing.T) {
823+ result := handler.buildImageName(tt.imageName, tt.reference)
824+ assert.Equal(t, tt.expected, result)
825+ })
826+ }
827+}
@@ -0,0 +1,827 @@
1+package container
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ inmocks "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/boundaries/out/mocks"
17+ "gordon/internal/domain"
18+)
19+
20+// domainToEnvFileName tests
21+
22+func TestDomainToEnvFileName(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ domain string
26+ expected string
27+ }{
28+ {
29+ name: "simple domain",
30+ domain: "app.example.com",
31+ expected: "app_example_com.env",
32+ },
33+ {
34+ name: "domain with port",
35+ domain: "app.example.com:8080",
36+ expected: "app_example_com_8080.env",
37+ },
38+ {
39+ name: "domain with path",
40+ domain: "app.example.com/api",
41+ expected: "app_example_com_api.env",
42+ },
43+ {
44+ name: "complex domain",
45+ domain: "sub.app.example.com:443/v1",
46+ expected: "sub_app_example_com_443_v1.env",
47+ },
48+ {
49+ name: "single word",
50+ domain: "localhost",
51+ expected: "localhost.env",
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := domainToEnvFileName(tt.domain)
58+ assert.Equal(t, tt.expected, result)
59+ })
60+ }
61+}
62+
63+// parseEnvFile tests
64+
65+func TestParseEnvFile(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ input string
69+ expected map[string]string
70+ wantErr bool
71+ }{
72+ {
73+ name: "simple key-value pairs",
74+ input: "FOO=bar\nBAZ=qux",
75+ expected: map[string]string{
76+ "FOO": "bar",
77+ "BAZ": "qux",
78+ },
79+ },
80+ {
81+ name: "with comments and empty lines",
82+ input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
83+ expected: map[string]string{
84+ "FOO": "bar",
85+ "BAZ": "qux",
86+ },
87+ },
88+ {
89+ name: "with quoted values - double quotes",
90+ input: `FOO="bar baz"`,
91+ expected: map[string]string{
92+ "FOO": "bar baz",
93+ },
94+ },
95+ {
96+ name: "with quoted values - single quotes",
97+ input: `FOO='bar baz'`,
98+ expected: map[string]string{
99+ "FOO": "bar baz",
100+ },
101+ },
102+ {
103+ name: "value with equals sign",
104+ input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
105+ expected: map[string]string{
106+ "DATABASE_URL": "postgres://user:pass@host/db?option=value",
107+ },
108+ },
109+ {
110+ name: "empty value",
111+ input: "EMPTY=",
112+ expected: map[string]string{
113+ "EMPTY": "",
114+ },
115+ },
116+ {
117+ name: "empty file",
118+ input: "",
119+ expected: map[string]string{},
120+ },
121+ {
122+ name: "only comments",
123+ input: "# comment 1\n# comment 2",
124+ expected: map[string]string{},
125+ },
126+ {
127+ name: "malformed line without equals",
128+ input: "MALFORMED\nGOOD=value",
129+ expected: map[string]string{"GOOD": "value"},
130+ },
131+ {
132+ name: "whitespace trimming",
133+ input: " FOO = bar \n BAZ=qux",
134+ expected: map[string]string{
135+ "FOO": "bar",
136+ "BAZ": "qux",
137+ },
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ result, err := parseEnvFile([]byte(tt.input))
144+
145+ if tt.wantErr {
146+ assert.Error(t, err)
147+ return
148+ }
149+
150+ require.NoError(t, err)
151+ assert.Equal(t, tt.expected, result)
152+ })
153+ }
154+}
155+
156+// parseConfigDigest tests
157+
158+func TestParseConfigDigest(t *testing.T) {
159+ tests := []struct {
160+ name string
161+ manifest map[string]any
162+ expected string
163+ wantErr bool
164+ }{
165+ {
166+ name: "valid manifest v2",
167+ manifest: map[string]any{
168+ "schemaVersion": 2,
169+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
170+ "config": map[string]any{
171+ "mediaType": "application/vnd.docker.container.image.v1+json",
172+ "digest": "sha256:abc123",
173+ "size": 1234,
174+ },
175+ },
176+ expected: "sha256:abc123",
177+ },
178+ {
179+ name: "OCI manifest",
180+ manifest: map[string]any{
181+ "schemaVersion": 2,
182+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
183+ "config": map[string]any{
184+ "mediaType": "application/vnd.oci.image.config.v1+json",
185+ "digest": "sha256:def456",
186+ "size": 5678,
187+ },
188+ },
189+ expected: "sha256:def456",
190+ },
191+ {
192+ name: "empty config digest",
193+ manifest: map[string]any{
194+ "schemaVersion": 2,
195+ "config": map[string]any{
196+ "digest": "",
197+ },
198+ },
199+ expected: "",
200+ },
201+ }
202+
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ data, err := json.Marshal(tt.manifest)
206+ require.NoError(t, err)
207+
208+ result, err := parseConfigDigest(data)
209+
210+ if tt.wantErr {
211+ assert.Error(t, err)
212+ return
213+ }
214+
215+ require.NoError(t, err)
216+ assert.Equal(t, tt.expected, result)
217+ })
218+ }
219+}
220+
221+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
222+ _, err := parseConfigDigest([]byte("invalid json"))
223+ assert.Error(t, err)
224+}
225+
226+// parseImageLabels tests
227+
228+func TestParseImageLabels(t *testing.T) {
229+ tests := []struct {
230+ name string
231+ config map[string]any
232+ expected *domain.ImageLabels
233+ }{
234+ {
235+ name: "single domain label",
236+ config: map[string]any{
237+ "config": map[string]any{
238+ "Labels": map[string]any{
239+ "gordon.domain": "app.example.com",
240+ },
241+ },
242+ },
243+ expected: &domain.ImageLabels{
244+ Domain: "app.example.com",
245+ },
246+ },
247+ {
248+ name: "multiple domains label",
249+ config: map[string]any{
250+ "config": map[string]any{
251+ "Labels": map[string]any{
252+ "gordon.domains": "app1.example.com, app2.example.com",
253+ },
254+ },
255+ },
256+ expected: &domain.ImageLabels{
257+ Domains: []string{"app1.example.com", "app2.example.com"},
258+ },
259+ },
260+ {
261+ name: "all gordon labels",
262+ config: map[string]any{
263+ "config": map[string]any{
264+ "Labels": map[string]any{
265+ "gordon.domain": "app.example.com",
266+ "gordon.health": "/healthz",
267+ "gordon.port": "8080",
268+ "gordon.env-file": ".env.prod",
269+ },
270+ },
271+ },
272+ expected: &domain.ImageLabels{
273+ Domain: "app.example.com",
274+ Health: "/healthz",
275+ Port: "8080",
276+ EnvFile: ".env.prod",
277+ },
278+ },
279+ {
280+ name: "no labels",
281+ config: map[string]any{
282+ "config": map[string]any{
283+ "Labels": nil,
284+ },
285+ },
286+ expected: &domain.ImageLabels{},
287+ },
288+ {
289+ name: "no gordon labels",
290+ config: map[string]any{
291+ "config": map[string]any{
292+ "Labels": map[string]any{
293+ "other.label": "value",
294+ },
295+ },
296+ },
297+ expected: &domain.ImageLabels{},
298+ },
299+ {
300+ name: "whitespace in labels",
301+ config: map[string]any{
302+ "config": map[string]any{
303+ "Labels": map[string]any{
304+ "gordon.domain": " app.example.com ",
305+ },
306+ },
307+ },
308+ expected: &domain.ImageLabels{
309+ Domain: "app.example.com",
310+ },
311+ },
312+ }
313+
314+ for _, tt := range tests {
315+ t.Run(tt.name, func(t *testing.T) {
316+ data, err := json.Marshal(tt.config)
317+ require.NoError(t, err)
318+
319+ result, err := parseImageLabels(data)
320+ require.NoError(t, err)
321+
322+ assert.Equal(t, tt.expected.Domain, result.Domain)
323+ assert.Equal(t, tt.expected.Domains, result.Domains)
324+ assert.Equal(t, tt.expected.Health, result.Health)
325+ assert.Equal(t, tt.expected.Port, result.Port)
326+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
327+ })
328+ }
329+}
330+
331+func TestParseImageLabels_InvalidJSON(t *testing.T) {
332+ _, err := parseImageLabels([]byte("invalid json"))
333+ assert.Error(t, err)
334+}
335+
336+// AutoRouteHandler tests
337+
338+func TestAutoRouteHandler_CanHandle(t *testing.T) {
339+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
340+ configSvc := inmocks.NewMockConfigService(t)
341+ containerSvc := inmocks.NewMockContainerService(t)
342+ blobStorage := mocks.NewMockBlobStorage(t)
343+
344+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
345+
346+ assert.True(t, handler.CanHandle(domain.EventImagePushed))
347+ assert.False(t, handler.CanHandle(domain.EventConfigReload))
348+ assert.False(t, handler.CanHandle(domain.EventManualReload))
349+}
350+
351+func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
352+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
353+ configSvc := inmocks.NewMockConfigService(t)
354+ containerSvc := inmocks.NewMockContainerService(t)
355+ blobStorage := mocks.NewMockBlobStorage(t)
356+
357+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
358+
359+ configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
360+
361+ event := domain.Event{
362+ ID: "event-123",
363+ Type: domain.EventImagePushed,
364+ Data: domain.ImagePushedPayload{
365+ Name: "myapp",
366+ Reference: "latest",
367+ Manifest: []byte(`{"schemaVersion": 2}`),
368+ },
369+ }
370+
371+ err := handler.Handle(event)
372+
373+ assert.NoError(t, err)
374+}
375+
376+func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
377+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
378+ configSvc := inmocks.NewMockConfigService(t)
379+ containerSvc := inmocks.NewMockContainerService(t)
380+ blobStorage := mocks.NewMockBlobStorage(t)
381+
382+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
383+
384+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
385+
386+ event := domain.Event{
387+ ID: "event-123",
388+ Type: domain.EventImagePushed,
389+ Data: "invalid payload type",
390+ }
391+
392+ err := handler.Handle(event)
393+
394+ // Handler skips invalid payload, doesn't error
395+ assert.NoError(t, err)
396+}
397+
398+func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
399+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
400+ configSvc := inmocks.NewMockConfigService(t)
401+ containerSvc := inmocks.NewMockContainerService(t)
402+ blobStorage := mocks.NewMockBlobStorage(t)
403+
404+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
405+
406+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
407+
408+ event := domain.Event{
409+ ID: "event-123",
410+ Type: domain.EventImagePushed,
411+ Data: domain.ImagePushedPayload{
412+ Name: "myapp",
413+ Reference: "latest",
414+ Manifest: []byte{},
415+ },
416+ }
417+
418+ err := handler.Handle(event)
419+
420+ assert.NoError(t, err)
421+}
422+
423+func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
424+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
425+ configSvc := inmocks.NewMockConfigService(t)
426+ containerSvc := inmocks.NewMockContainerService(t)
427+ blobStorage := mocks.NewMockBlobStorage(t)
428+
429+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
430+
431+ // Build manifest with config digest
432+ manifest := map[string]any{
433+ "schemaVersion": 2,
434+ "config": map[string]any{
435+ "digest": "sha256:configdigest123",
436+ },
437+ }
438+ manifestData, _ := json.Marshal(manifest)
439+
440+ // Build image config with gordon labels
441+ imageConfig := map[string]any{
442+ "config": map[string]any{
443+ "Labels": map[string]any{
444+ "gordon.domain": "app.example.com",
445+ },
446+ },
447+ }
448+ configData, _ := json.Marshal(imageConfig)
449+
450+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
451+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
452+
453+ // No existing routes
454+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
455+
456+ // Expect route to be added
457+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
458+ Domain: "app.example.com",
459+ Image: "myapp:latest",
460+ }).Return(nil)
461+
462+ // Expect deploy to be triggered
463+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
464+ Domain: "app.example.com",
465+ Image: "myapp:latest",
466+ }).Return(&domain.Container{ID: "container-1"}, nil)
467+
468+ event := domain.Event{
469+ ID: "event-123",
470+ Type: domain.EventImagePushed,
471+ Data: domain.ImagePushedPayload{
472+ Name: "myapp",
473+ Reference: "latest",
474+ Manifest: manifestData,
475+ },
476+ }
477+
478+ err := handler.Handle(event)
479+
480+ assert.NoError(t, err)
481+}
482+
483+func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
484+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
485+ configSvc := inmocks.NewMockConfigService(t)
486+ containerSvc := inmocks.NewMockContainerService(t)
487+ blobStorage := mocks.NewMockBlobStorage(t)
488+
489+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
490+
491+ // Build manifest with config digest
492+ manifest := map[string]any{
493+ "schemaVersion": 2,
494+ "config": map[string]any{
495+ "digest": "sha256:configdigest123",
496+ },
497+ }
498+ manifestData, _ := json.Marshal(manifest)
499+
500+ // Build image config with gordon labels
501+ imageConfig := map[string]any{
502+ "config": map[string]any{
503+ "Labels": map[string]any{
504+ "gordon.domain": "app.example.com",
505+ },
506+ },
507+ }
508+ configData, _ := json.Marshal(imageConfig)
509+
510+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
511+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
512+
513+ // Existing route with different image
514+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
515+ {Domain: "app.example.com", Image: "myapp:v1"},
516+ })
517+
518+ // Expect route to be updated
519+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
520+ Domain: "app.example.com",
521+ Image: "myapp:v2",
522+ }).Return(nil)
523+
524+ // Expect deploy to be triggered
525+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
526+ Domain: "app.example.com",
527+ Image: "myapp:v2",
528+ }).Return(&domain.Container{ID: "container-1"}, nil)
529+
530+ event := domain.Event{
531+ ID: "event-123",
532+ Type: domain.EventImagePushed,
533+ Data: domain.ImagePushedPayload{
534+ Name: "myapp",
535+ Reference: "v2",
536+ Manifest: manifestData,
537+ },
538+ }
539+
540+ err := handler.Handle(event)
541+
542+ assert.NoError(t, err)
543+}
544+
545+func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
546+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
547+ configSvc := inmocks.NewMockConfigService(t)
548+ containerSvc := inmocks.NewMockContainerService(t)
549+ blobStorage := mocks.NewMockBlobStorage(t)
550+
551+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
552+
553+ // Build manifest with config digest
554+ manifest := map[string]any{
555+ "schemaVersion": 2,
556+ "config": map[string]any{
557+ "digest": "sha256:configdigest123",
558+ },
559+ }
560+ manifestData, _ := json.Marshal(manifest)
561+
562+ // Build image config with gordon labels
563+ imageConfig := map[string]any{
564+ "config": map[string]any{
565+ "Labels": map[string]any{
566+ "gordon.domain": "app.example.com",
567+ },
568+ },
569+ }
570+ configData, _ := json.Marshal(imageConfig)
571+
572+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
573+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
574+
575+ // Existing route with same image - no update needed
576+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
577+ {Domain: "app.example.com", Image: "myapp:latest"},
578+ })
579+
580+ // No AddRoute, UpdateRoute, or Deploy calls expected
581+
582+ event := domain.Event{
583+ ID: "event-123",
584+ Type: domain.EventImagePushed,
585+ Data: domain.ImagePushedPayload{
586+ Name: "myapp",
587+ Reference: "latest",
588+ Manifest: manifestData,
589+ },
590+ }
591+
592+ err := handler.Handle(event)
593+
594+ assert.NoError(t, err)
595+}
596+
597+func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
598+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
599+ configSvc := inmocks.NewMockConfigService(t)
600+ containerSvc := inmocks.NewMockContainerService(t)
601+ blobStorage := mocks.NewMockBlobStorage(t)
602+
603+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
604+
605+ // Build manifest with config digest
606+ manifest := map[string]any{
607+ "schemaVersion": 2,
608+ "config": map[string]any{
609+ "digest": "sha256:configdigest123",
610+ },
611+ }
612+ manifestData, _ := json.Marshal(manifest)
613+
614+ // Build image config with no gordon labels
615+ imageConfig := map[string]any{
616+ "config": map[string]any{
617+ "Labels": map[string]any{
618+ "other.label": "value",
619+ },
620+ },
621+ }
622+ configData, _ := json.Marshal(imageConfig)
623+
624+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
625+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
626+
627+ // No route operations expected
628+
629+ event := domain.Event{
630+ ID: "event-123",
631+ Type: domain.EventImagePushed,
632+ Data: domain.ImagePushedPayload{
633+ Name: "myapp",
634+ Reference: "latest",
635+ Manifest: manifestData,
636+ },
637+ }
638+
639+ err := handler.Handle(event)
640+
641+ assert.NoError(t, err)
642+}
643+
644+func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
645+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
646+ configSvc := inmocks.NewMockConfigService(t)
647+ containerSvc := inmocks.NewMockContainerService(t)
648+ blobStorage := mocks.NewMockBlobStorage(t)
649+
650+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
651+
652+ // Build manifest with config digest
653+ manifest := map[string]any{
654+ "schemaVersion": 2,
655+ "config": map[string]any{
656+ "digest": "sha256:configdigest123",
657+ },
658+ }
659+ manifestData, _ := json.Marshal(manifest)
660+
661+ // Build image config with multiple domains
662+ imageConfig := map[string]any{
663+ "config": map[string]any{
664+ "Labels": map[string]any{
665+ "gordon.domain": "app.example.com",
666+ "gordon.domains": "api.example.com, www.example.com",
667+ },
668+ },
669+ }
670+ configData, _ := json.Marshal(imageConfig)
671+
672+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
673+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
674+
675+ // No existing routes
676+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
677+
678+ // Expect routes to be added for all three domains
679+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
680+ Domain: "app.example.com",
681+ Image: "myapp:latest",
682+ }).Return(nil)
683+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
684+ Domain: "api.example.com",
685+ Image: "myapp:latest",
686+ }).Return(nil)
687+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
688+ Domain: "www.example.com",
689+ Image: "myapp:latest",
690+ }).Return(nil)
691+
692+ // Expect deploy for all three
693+ containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
694+
695+ event := domain.Event{
696+ ID: "event-123",
697+ Type: domain.EventImagePushed,
698+ Data: domain.ImagePushedPayload{
699+ Name: "myapp",
700+ Reference: "latest",
701+ Manifest: manifestData,
702+ },
703+ }
704+
705+ err := handler.Handle(event)
706+
707+ assert.NoError(t, err)
708+}
709+
710+func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
711+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
712+ configSvc := inmocks.NewMockConfigService(t)
713+ blobStorage := mocks.NewMockBlobStorage(t)
714+
715+ // Create handler without container service
716+ handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
717+
718+ route := domain.Route{
719+ Domain: "app.example.com",
720+ Image: "myapp:latest",
721+ }
722+
723+ // Should not panic, just log and skip
724+ handler.triggerDeploy(ctx, route)
725+}
726+
727+// collectDomains tests
728+
729+func TestAutoRouteHandler_CollectDomains(t *testing.T) {
730+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
731+ configSvc := inmocks.NewMockConfigService(t)
732+ containerSvc := inmocks.NewMockContainerService(t)
733+ blobStorage := mocks.NewMockBlobStorage(t)
734+
735+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
736+
737+ tests := []struct {
738+ name string
739+ labels *domain.ImageLabels
740+ expected []string
741+ }{
742+ {
743+ name: "single domain",
744+ labels: &domain.ImageLabels{
745+ Domain: "app.example.com",
746+ },
747+ expected: []string{"app.example.com"},
748+ },
749+ {
750+ name: "domains list only",
751+ labels: &domain.ImageLabels{
752+ Domains: []string{"api.example.com", "www.example.com"},
753+ },
754+ expected: []string{"api.example.com", "www.example.com"},
755+ },
756+ {
757+ name: "both domain and domains",
758+ labels: &domain.ImageLabels{
759+ Domain: "app.example.com",
760+ Domains: []string{"api.example.com"},
761+ },
762+ expected: []string{"app.example.com", "api.example.com"},
763+ },
764+ {
765+ name: "empty labels",
766+ labels: &domain.ImageLabels{},
767+ expected: nil,
768+ },
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.name, func(t *testing.T) {
773+ result := handler.collectDomains(tt.labels)
774+ assert.Equal(t, tt.expected, result)
775+ })
776+ }
777+}
778+
779+// buildImageName tests
780+
781+func TestAutoRouteHandler_BuildImageName(t *testing.T) {
782+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
783+ configSvc := inmocks.NewMockConfigService(t)
784+ containerSvc := inmocks.NewMockContainerService(t)
785+ blobStorage := mocks.NewMockBlobStorage(t)
786+
787+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
788+
789+ tests := []struct {
790+ name string
791+ imageName string
792+ reference string
793+ expected string
794+ }{
795+ {
796+ name: "with tag",
797+ imageName: "myapp",
798+ reference: "latest",
799+ expected: "myapp:latest",
800+ },
801+ {
802+ name: "with version tag",
803+ imageName: "myapp",
804+ reference: "v1.2.3",
805+ expected: "myapp:v1.2.3",
806+ },
807+ {
808+ name: "empty reference",
809+ imageName: "myapp",
810+ reference: "",
811+ expected: "myapp",
812+ },
813+ {
814+ name: "with digest",
815+ imageName: "myapp",
816+ reference: "sha256:abc123",
817+ expected: "myapp:sha256:abc123",
818+ },
819+ }
820+
821+ for _, tt := range tests {
822+ t.Run(tt.name, func(t *testing.T) {
823+ result := handler.buildImageName(tt.imageName, tt.reference)
824+ assert.Equal(t, tt.expected, result)
825+ })
826+ }
827+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close