v2.31.0-dev8 ec8c5b9

autoroute_test.go RawHistoryFolder

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

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