v2.30.0-pre4 15d9fe3

autoroute_test.go RawHistoryFolder

1package container
2
3import (
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 "github.com/bnema/gordon/internal/boundaries/in/mocks"
16	"github.com/bnema/gordon/internal/boundaries/out/mocks"
17	"github.com/bnema/gordon/internal/domain"
18	"github.com/bnema/gordon/internal/usecase/auto"
19)
20
21// domainToEnvFileName tests
22
23func TestDomainToEnvFileName(t *testing.T) {
24	tests := []struct {
25		name     string
26		domain   string
27		expected string
28	}{
29		{
30			name:     "simple domain",
31			domain:   "app.example.com",
32			expected: "YXBwLmV4YW1wbGUuY29t.env",
33		},
34		{
35			name:     "domain with port",
36			domain:   "app.example.com:8080",
37			expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
38		},
39		{
40			name:     "domain with path",
41			domain:   "app.example.com/api",
42			expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env",
43		},
44		{
45			name:     "complex domain",
46			domain:   "sub.app.example.com:443/v1",
47			expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
48		},
49		{
50			name:     "single word",
51			domain:   "localhost",
52			expected: "bG9jYWxob3N0.env",
53		},
54	}
55
56	for _, tt := range tests {
57		t.Run(tt.name, func(t *testing.T) {
58			result, err := domainToEnvFileName(tt.domain)
59			require.NoError(t, err)
60			assert.Equal(t, tt.expected, result)
61		})
62	}
63}
64
65// parseEnvFile tests
66
67func TestParseEnvFile(t *testing.T) {
68	tests := []struct {
69		name     string
70		input    string
71		expected map[string]string
72		wantErr  bool
73	}{
74		{
75			name:  "simple key-value pairs",
76			input: "FOO=bar\nBAZ=qux",
77			expected: map[string]string{
78				"FOO": "bar",
79				"BAZ": "qux",
80			},
81		},
82		{
83			name:  "with comments and empty lines",
84			input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
85			expected: map[string]string{
86				"FOO": "bar",
87				"BAZ": "qux",
88			},
89		},
90		{
91			name:  "with quoted values - double quotes",
92			input: `FOO="bar baz"`,
93			expected: map[string]string{
94				"FOO": "bar baz",
95			},
96		},
97		{
98			name:  "with quoted values - single quotes",
99			input: `FOO='bar baz'`,
100			expected: map[string]string{
101				"FOO": "bar baz",
102			},
103		},
104		{
105			name:  "value with equals sign",
106			input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
107			expected: map[string]string{
108				"DATABASE_URL": "postgres://user:pass@host/db?option=value",
109			},
110		},
111		{
112			name:  "empty value",
113			input: "EMPTY=",
114			expected: map[string]string{
115				"EMPTY": "",
116			},
117		},
118		{
119			name:     "empty file",
120			input:    "",
121			expected: map[string]string{},
122		},
123		{
124			name:     "only comments",
125			input:    "# comment 1\n# comment 2",
126			expected: map[string]string{},
127		},
128		{
129			name:     "malformed line without equals",
130			input:    "MALFORMED\nGOOD=value",
131			expected: map[string]string{"GOOD": "value"},
132		},
133		{
134			name:  "whitespace trimming",
135			input: "  FOO  =  bar  \n  BAZ=qux",
136			expected: map[string]string{
137				"FOO": "bar",
138				"BAZ": "qux",
139			},
140		},
141	}
142
143	for _, tt := range tests {
144		t.Run(tt.name, func(t *testing.T) {
145			result, err := parseEnvFile([]byte(tt.input))
146
147			if tt.wantErr {
148				assert.Error(t, err)
149				return
150			}
151
152			require.NoError(t, err)
153			assert.Equal(t, tt.expected, result)
154		})
155	}
156}
157
158// parseConfigDigest tests — now delegated to auto package; tested there directly.
159// These tests call auto.ParseConfigDigest to maintain coverage for this package.
160
161func TestParseConfigDigest(t *testing.T) {
162	tests := []struct {
163		name     string
164		manifest map[string]any
165		expected string
166		wantErr  bool
167	}{
168		{
169			name: "valid manifest v2",
170			manifest: map[string]any{
171				"schemaVersion": 2,
172				"mediaType":     "application/vnd.docker.distribution.manifest.v2+json",
173				"config": map[string]any{
174					"mediaType": "application/vnd.docker.container.image.v1+json",
175					"digest":    "sha256:abc123",
176					"size":      1234,
177				},
178			},
179			expected: "sha256:abc123",
180		},
181		{
182			name: "OCI manifest",
183			manifest: map[string]any{
184				"schemaVersion": 2,
185				"mediaType":     "application/vnd.oci.image.manifest.v1+json",
186				"config": map[string]any{
187					"mediaType": "application/vnd.oci.image.config.v1+json",
188					"digest":    "sha256:def456",
189					"size":      5678,
190				},
191			},
192			expected: "sha256:def456",
193		},
194		{
195			name: "empty config digest",
196			manifest: map[string]any{
197				"schemaVersion": 2,
198				"config": map[string]any{
199					"digest": "",
200				},
201			},
202			expected: "",
203		},
204	}
205
206	for _, tt := range tests {
207		t.Run(tt.name, func(t *testing.T) {
208			data, err := json.Marshal(tt.manifest)
209			require.NoError(t, err)
210
211			result, err := auto.ParseConfigDigest(data)
212
213			if tt.wantErr {
214				assert.Error(t, err)
215				return
216			}
217
218			require.NoError(t, err)
219			assert.Equal(t, tt.expected, result)
220		})
221	}
222}
223
224func TestParseConfigDigest_InvalidJSON(t *testing.T) {
225	_, err := auto.ParseConfigDigest([]byte("invalid json"))
226	assert.Error(t, err)
227}
228
229// parseImageLabels tests — now delegated to auto package; tested there directly.
230
231func TestParseImageLabels(t *testing.T) {
232	tests := []struct {
233		name     string
234		config   map[string]any
235		expected *domain.ImageLabels
236	}{
237		{
238			name: "single domain label",
239			config: map[string]any{
240				"config": map[string]any{
241					"Labels": map[string]any{
242						"gordon.domain": "app.example.com",
243					},
244				},
245			},
246			expected: &domain.ImageLabels{
247				Domain: "app.example.com",
248			},
249		},
250		{
251			name: "multiple domains label",
252			config: map[string]any{
253				"config": map[string]any{
254					"Labels": map[string]any{
255						"gordon.domains": "app1.example.com, app2.example.com",
256					},
257				},
258			},
259			expected: &domain.ImageLabels{
260				Domains: []string{"app1.example.com", "app2.example.com"},
261			},
262		},
263		{
264			name: "all gordon labels",
265			config: map[string]any{
266				"config": map[string]any{
267					"Labels": map[string]any{
268						"gordon.domain":     "app.example.com",
269						"gordon.health":     "/healthz",
270						"gordon.proxy.port": "8080",
271						"gordon.env-file":   ".env.prod",
272					},
273				},
274			},
275			expected: &domain.ImageLabels{
276				Domain:  "app.example.com",
277				Health:  "/healthz",
278				Port:    "8080",
279				EnvFile: ".env.prod",
280			},
281		},
282		{
283			name: "no labels",
284			config: map[string]any{
285				"config": map[string]any{
286					"Labels": nil,
287				},
288			},
289			expected: &domain.ImageLabels{},
290		},
291		{
292			name: "no gordon labels",
293			config: map[string]any{
294				"config": map[string]any{
295					"Labels": map[string]any{
296						"other.label": "value",
297					},
298				},
299			},
300			expected: &domain.ImageLabels{},
301		},
302		{
303			name: "whitespace in labels",
304			config: map[string]any{
305				"config": map[string]any{
306					"Labels": map[string]any{
307						"gordon.domain": "  app.example.com  ",
308					},
309				},
310			},
311			expected: &domain.ImageLabels{
312				Domain: "app.example.com",
313			},
314		},
315	}
316
317	for _, tt := range tests {
318		t.Run(tt.name, func(t *testing.T) {
319			data, err := json.Marshal(tt.config)
320			require.NoError(t, err)
321
322			result, err := auto.ParseImageLabels(data)
323			require.NoError(t, err)
324
325			assert.Equal(t, tt.expected.Domain, result.Domain)
326			assert.Equal(t, tt.expected.Domains, result.Domains)
327			assert.Equal(t, tt.expected.Health, result.Health)
328			assert.Equal(t, tt.expected.Port, result.Port)
329			assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
330		})
331	}
332}
333
334func TestParseImageLabels_InvalidJSON(t *testing.T) {
335	_, err := auto.ParseImageLabels([]byte("invalid json"))
336	assert.Error(t, err)
337}
338
339func TestMatchesDomainAllowlist(t *testing.T) {
340	tests := []struct {
341		name     string
342		domain   string
343		patterns []string
344		want     bool
345	}{
346		{name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
347		{name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
348		{name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
349		{name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
350		{name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
351		{name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
352		{name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
353		{name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
354	}
355
356	for _, tt := range tests {
357		t.Run(tt.name, func(t *testing.T) {
358			assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
359		})
360	}
361}
362
363func TestExtractRepoName(t *testing.T) {
364	tests := []struct {
365		name           string
366		imageRef       string
367		registryDomain string
368		want           string
369	}{
370		{name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
371		{name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
372		{name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
373		{name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
374		{name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
375		{name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
376		{name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
377	}
378
379	for _, tt := range tests {
380		t.Run(tt.name, func(t *testing.T) {
381			assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
382		})
383	}
384}
385
386// AutoRouteHandler tests
387
388func TestAutoRouteHandler_CanHandle(t *testing.T) {
389	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
390	configSvc := inmocks.NewMockConfigService(t)
391	containerSvc := inmocks.NewMockContainerService(t)
392	blobStorage := mocks.NewMockBlobStorage(t)
393
394	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
395
396	assert.True(t, handler.CanHandle(domain.EventImagePushed))
397	assert.False(t, handler.CanHandle(domain.EventConfigReload))
398	assert.False(t, handler.CanHandle(domain.EventManualReload))
399}
400
401func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
402	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
403	configSvc := inmocks.NewMockConfigService(t)
404	containerSvc := inmocks.NewMockContainerService(t)
405	blobStorage := mocks.NewMockBlobStorage(t)
406
407	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
408
409	configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
410
411	event := domain.Event{
412		ID:   "event-123",
413		Type: domain.EventImagePushed,
414		Data: domain.ImagePushedPayload{
415			Name:      "myapp",
416			Reference: "latest",
417			Manifest:  []byte(`{"schemaVersion": 2}`),
418		},
419	}
420
421	err := handler.Handle(context.Background(), event)
422
423	assert.NoError(t, err)
424}
425
426func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
427	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
428	configSvc := inmocks.NewMockConfigService(t)
429	containerSvc := inmocks.NewMockContainerService(t)
430	blobStorage := mocks.NewMockBlobStorage(t)
431
432	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
433
434	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
435
436	event := domain.Event{
437		ID:   "event-123",
438		Type: domain.EventImagePushed,
439		Data: "invalid payload type",
440	}
441
442	err := handler.Handle(context.Background(), event)
443
444	// Handler skips invalid payload, doesn't error
445	assert.NoError(t, err)
446}
447
448func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
449	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
450	configSvc := inmocks.NewMockConfigService(t)
451	containerSvc := inmocks.NewMockContainerService(t)
452	blobStorage := mocks.NewMockBlobStorage(t)
453
454	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
455
456	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
457
458	event := domain.Event{
459		ID:   "event-123",
460		Type: domain.EventImagePushed,
461		Data: domain.ImagePushedPayload{
462			Name:      "myapp",
463			Reference: "latest",
464			Manifest:  []byte{},
465		},
466	}
467
468	err := handler.Handle(context.Background(), event)
469
470	assert.NoError(t, err)
471}
472
473func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
474	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
475	configSvc := inmocks.NewMockConfigService(t)
476	containerSvc := inmocks.NewMockContainerService(t)
477	blobStorage := mocks.NewMockBlobStorage(t)
478
479	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
480
481	// Build manifest with config digest
482	manifest := map[string]any{
483		"schemaVersion": 2,
484		"config": map[string]any{
485			"digest": "sha256:configdigest123",
486		},
487	}
488	manifestData, _ := json.Marshal(manifest)
489
490	// Build image config with gordon labels
491	imageConfig := map[string]any{
492		"config": map[string]any{
493			"Labels": map[string]any{
494				"gordon.domain": "app.example.com",
495			},
496		},
497	}
498	configData, _ := json.Marshal(imageConfig)
499
500	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
501	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
502	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com"}, nil)
503
504	// No existing routes
505	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
506
507	// Expect route to be added
508	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
509		Domain: "app.example.com",
510		Image:  "myapp:latest",
511	}).Return(nil)
512
513	// Expect deploy to be triggered
514	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
515		Domain: "app.example.com",
516		Image:  "myapp:latest",
517	}).Return(&domain.Container{ID: "container-1"}, nil)
518
519	event := domain.Event{
520		ID:   "event-123",
521		Type: domain.EventImagePushed,
522		Data: domain.ImagePushedPayload{
523			Name:      "myapp",
524			Reference: "latest",
525			Manifest:  manifestData,
526		},
527	}
528
529	err := handler.Handle(context.Background(), event)
530
531	assert.NoError(t, err)
532}
533
534func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
535	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
536	configSvc := inmocks.NewMockConfigService(t)
537	containerSvc := inmocks.NewMockContainerService(t)
538	blobStorage := mocks.NewMockBlobStorage(t)
539
540	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
541
542	// Build manifest with config digest
543	manifest := map[string]any{
544		"schemaVersion": 2,
545		"config": map[string]any{
546			"digest": "sha256:configdigest123",
547		},
548	}
549	manifestData, _ := json.Marshal(manifest)
550
551	// Build image config with gordon labels
552	imageConfig := map[string]any{
553		"config": map[string]any{
554			"Labels": map[string]any{
555				"gordon.domain": "app.example.com",
556			},
557		},
558	}
559	configData, _ := json.Marshal(imageConfig)
560
561	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
562	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
563	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
564
565	// Existing route with different image
566	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
567		{Domain: "app.example.com", Image: "myapp:v1"},
568	})
569
570	// Expect route to be updated
571	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
572		Domain: "app.example.com",
573		Image:  "myapp:v2",
574	}).Return(nil)
575
576	// Expect deploy to be triggered
577	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
578		Domain: "app.example.com",
579		Image:  "myapp:v2",
580	}).Return(&domain.Container{ID: "container-1"}, nil)
581
582	event := domain.Event{
583		ID:   "event-123",
584		Type: domain.EventImagePushed,
585		Data: domain.ImagePushedPayload{
586			Name:      "myapp",
587			Reference: "v2",
588			Manifest:  manifestData,
589		},
590	}
591
592	err := handler.Handle(context.Background(), event)
593
594	assert.NoError(t, err)
595}
596
597func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(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 gordon labels
615	imageConfig := map[string]any{
616		"config": map[string]any{
617			"Labels": map[string]any{
618				"gordon.domain": "app.example.com",
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	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
627
628	// Existing route with same image - no update needed
629	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
630		{Domain: "app.example.com", Image: "myapp:latest"},
631	})
632
633	// No AddRoute, UpdateRoute, or Deploy calls expected
634
635	event := domain.Event{
636		ID:   "event-123",
637		Type: domain.EventImagePushed,
638		Data: domain.ImagePushedPayload{
639			Name:      "myapp",
640			Reference: "latest",
641			Manifest:  manifestData,
642		},
643	}
644
645	err := handler.Handle(context.Background(), event)
646
647	assert.NoError(t, err)
648}
649
650func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
651	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
652	configSvc := inmocks.NewMockConfigService(t)
653	containerSvc := inmocks.NewMockContainerService(t)
654	blobStorage := mocks.NewMockBlobStorage(t)
655
656	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
657
658	// Build manifest with config digest
659	manifest := map[string]any{
660		"schemaVersion": 2,
661		"config": map[string]any{
662			"digest": "sha256:configdigest123",
663		},
664	}
665	manifestData, _ := json.Marshal(manifest)
666
667	// Build image config with no gordon labels
668	imageConfig := map[string]any{
669		"config": map[string]any{
670			"Labels": map[string]any{
671				"other.label": "value",
672			},
673		},
674	}
675	configData, _ := json.Marshal(imageConfig)
676
677	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
678	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
679
680	// No route operations expected
681
682	event := domain.Event{
683		ID:   "event-123",
684		Type: domain.EventImagePushed,
685		Data: domain.ImagePushedPayload{
686			Name:      "myapp",
687			Reference: "latest",
688			Manifest:  manifestData,
689		},
690	}
691
692	err := handler.Handle(context.Background(), event)
693
694	assert.NoError(t, err)
695}
696
697func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
698	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
699	configSvc := inmocks.NewMockConfigService(t)
700	containerSvc := inmocks.NewMockContainerService(t)
701	blobStorage := mocks.NewMockBlobStorage(t)
702
703	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
704
705	// Build manifest with config digest
706	manifest := map[string]any{
707		"schemaVersion": 2,
708		"config": map[string]any{
709			"digest": "sha256:configdigest123",
710		},
711	}
712	manifestData, _ := json.Marshal(manifest)
713
714	// Build image config with multiple domains
715	imageConfig := map[string]any{
716		"config": map[string]any{
717			"Labels": map[string]any{
718				"gordon.domain":  "app.example.com",
719				"gordon.domains": "api.example.com, www.example.com",
720			},
721		},
722	}
723	configData, _ := json.Marshal(imageConfig)
724
725	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
726	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
727	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com", "api.example.com", "www.example.com"}, nil).Once()
728
729	// No existing routes
730	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
731
732	// Expect routes to be added for all three domains
733	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
734		Domain: "app.example.com",
735		Image:  "myapp:latest",
736	}).Return(nil)
737	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
738		Domain: "api.example.com",
739		Image:  "myapp:latest",
740	}).Return(nil)
741	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
742		Domain: "www.example.com",
743		Image:  "myapp:latest",
744	}).Return(nil)
745
746	// Expect deploy for all three
747	containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
748
749	event := domain.Event{
750		ID:   "event-123",
751		Type: domain.EventImagePushed,
752		Data: domain.ImagePushedPayload{
753			Name:      "myapp",
754			Reference: "latest",
755			Manifest:  manifestData,
756		},
757	}
758
759	err := handler.Handle(context.Background(), event)
760
761	assert.NoError(t, err)
762}
763
764func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
765	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
766	configSvc := inmocks.NewMockConfigService(t)
767	blobStorage := mocks.NewMockBlobStorage(t)
768
769	// Create handler without container service
770	handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
771
772	route := domain.Route{
773		Domain: "app.example.com",
774		Image:  "myapp:latest",
775	}
776
777	// Should not panic, just log and skip
778	handler.triggerDeploy(ctx, route)
779}
780
781func TestAutoRouteHandler_NewDomain_Allowed(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	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
787
788	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
789	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
790	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
791
792	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
793	assert.True(t, created)
794}
795
796func TestAutoRouteHandler_NewDomain_NotAllowed(t *testing.T) {
797	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
798	configSvc := inmocks.NewMockConfigService(t)
799	containerSvc := inmocks.NewMockContainerService(t)
800	blobStorage := mocks.NewMockBlobStorage(t)
801	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
802
803	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
804
805	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.allowed.com"})
806	assert.False(t, created)
807}
808
809func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
810	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
811	configSvc := inmocks.NewMockConfigService(t)
812	containerSvc := inmocks.NewMockContainerService(t)
813	blobStorage := mocks.NewMockBlobStorage(t)
814	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
815
816	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})
817	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)
818	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)
819
820	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
821	assert.False(t, created)
822}
823
824func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
825	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
826	configSvc := inmocks.NewMockConfigService(t)
827	containerSvc := inmocks.NewMockContainerService(t)
828	blobStorage := mocks.NewMockBlobStorage(t)
829	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
830
831	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "oldapp:v1"}})
832
833	created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "newapp:v2", []string{"*.example.com"})
834	assert.False(t, created)
835}
836
837func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
838	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
839	configSvc := inmocks.NewMockConfigService(t)
840	containerSvc := inmocks.NewMockContainerService(t)
841	blobStorage := mocks.NewMockBlobStorage(t)
842	extractor := &testEnvExtractor{}
843
844	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, t.TempDir())
845
846	configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
847	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
848	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
849	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
850	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
851
852	handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
853	handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
854
855	assert.Equal(t, 1, extractor.calls)
856	assert.Equal(t, "registry.example.com/myapp:latest", extractor.lastImage)
857	assert.Equal(t, ".env", extractor.lastPath)
858}
859
860type testEnvExtractor struct {
861	calls     int
862	lastImage string
863	lastPath  string
864}
865
866func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
867	e.calls++
868	e.lastImage = imageRef
869	e.lastPath = envFilePath
870	return []byte("FOO=bar\n"), nil
871}
872
873// collectDomains tests
874
875func TestAutoRouteHandler_CollectDomains(t *testing.T) {
876	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
877	configSvc := inmocks.NewMockConfigService(t)
878	containerSvc := inmocks.NewMockContainerService(t)
879	blobStorage := mocks.NewMockBlobStorage(t)
880
881	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
882
883	tests := []struct {
884		name     string
885		labels   *domain.ImageLabels
886		expected []string
887	}{
888		{
889			name: "single domain",
890			labels: &domain.ImageLabels{
891				Domain: "app.example.com",
892			},
893			expected: []string{"app.example.com"},
894		},
895		{
896			name: "domains list only",
897			labels: &domain.ImageLabels{
898				Domains: []string{"api.example.com", "www.example.com"},
899			},
900			expected: []string{"api.example.com", "www.example.com"},
901		},
902		{
903			name: "both domain and domains",
904			labels: &domain.ImageLabels{
905				Domain:  "app.example.com",
906				Domains: []string{"api.example.com"},
907			},
908			expected: []string{"app.example.com", "api.example.com"},
909		},
910		{
911			name:     "empty labels",
912			labels:   &domain.ImageLabels{},
913			expected: nil,
914		},
915	}
916
917	for _, tt := range tests {
918		t.Run(tt.name, func(t *testing.T) {
919			result := handler.collectDomains(tt.labels)
920			assert.Equal(t, tt.expected, result)
921		})
922	}
923}
924
925// buildImageName tests
926
927func TestAutoRouteHandler_BuildImageName(t *testing.T) {
928	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
929	configSvc := inmocks.NewMockConfigService(t)
930	containerSvc := inmocks.NewMockContainerService(t)
931	blobStorage := mocks.NewMockBlobStorage(t)
932
933	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
934
935	tests := []struct {
936		name      string
937		imageName string
938		reference string
939		expected  string
940	}{
941		{
942			name:      "with tag",
943			imageName: "myapp",
944			reference: "latest",
945			expected:  "myapp:latest",
946		},
947		{
948			name:      "with version tag",
949			imageName: "myapp",
950			reference: "v1.2.3",
951			expected:  "myapp:v1.2.3",
952		},
953		{
954			name:      "empty reference",
955			imageName: "myapp",
956			reference: "",
957			expected:  "myapp",
958		},
959		{
960			name:      "with digest",
961			imageName: "myapp",
962			reference: "sha256:abc123",
963			expected:  "myapp@sha256:abc123",
964		},
965	}
966
967	for _, tt := range tests {
968		t.Run(tt.name, func(t *testing.T) {
969			result := handler.buildImageName(tt.imageName, tt.reference)
970			assert.Equal(t, tt.expected, result)
971		})
972	}
973}
974
975// buildFullImageRef tests
976
977func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) {
978	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
979	configSvc := inmocks.NewMockConfigService(t)
980	containerSvc := inmocks.NewMockContainerService(t)
981	blobStorage := mocks.NewMockBlobStorage(t)
982
983	tests := []struct {
984		name           string
985		registryDomain string
986		imageName      string
987		expected       string
988	}{
989		{
990			name:           "simple image with registry",
991			registryDomain: "registry.example.com",
992			imageName:      "myapp:latest",
993			expected:       "registry.example.com/myapp:latest",
994		},
995		{
996			name:           "already has registry prefix",
997			registryDomain: "registry.example.com",
998			imageName:      "registry.example.com/myapp:latest",
999			expected:       "registry.example.com/myapp:latest",
1000		},
1001		{
1002			name:           "external registry reference",
1003			registryDomain: "registry.example.com",
1004			imageName:      "docker.io/library/nginx:latest",
1005			expected:       "docker.io/library/nginx:latest",
1006		},
1007		{
1008			name:           "digest reference",
1009			registryDomain: "registry.example.com",
1010			imageName:      "myapp@sha256:abc123def456",
1011			expected:       "registry.example.com/myapp@sha256:abc123def456",
1012		},
1013		{
1014			name:           "empty registry domain",
1015			registryDomain: "",
1016			imageName:      "myapp:latest",
1017			expected:       "myapp:latest",
1018		},
1019		{
1020			name:           "image without tag",
1021			registryDomain: "registry.example.com",
1022			imageName:      "myapp",
1023			expected:       "registry.example.com/myapp",
1024		},
1025	}
1026
1027	for _, tt := range tests {
1028		t.Run(tt.name, func(t *testing.T) {
1029			handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain)
1030			result := handler.buildFullImageRef(tt.imageName)
1031			assert.Equal(t, tt.expected, result)
1032		})
1033	}
1034}

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