detached 07aac7d

refactor: extract shared auto-route foundations into usecase/auto

Extract matchesDomainAllowlist, extractRepoName, parseConfigDigest,
parseImageLabels, and label-extraction logic from autoroute.go into a
new internal/usecase/auto package so AutoPreviewHandler (Task 9) can
share the same implementations without duplication.

- auto/validation.go: MatchesDomainAllowlist, ExtractRepoName
- auto/labels.go: ParseConfigDigest, ParseImageLabels, ExtractLabels
- auto/validation_test.go, auto/labels_test.go: full coverage
- autoroute.go: replaced private calls with auto.* equivalents
- autoroute_test.go: updated to call exported auto.* functions
commit
07aac7d57801a2e86e1780a5a44947959e98c5da
author
brice · · 2026-03-19 04:50 UTC
committer
brice · · 2026-03-19 05:13 UTC
parent
ca9fb14
signature
unsigned
6 files · +526 -159
A internal/usecase/auto/labels.go +118 -0
@@ -0,0 +1,118 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "strings"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
15+type manifestSchema struct {
16+ SchemaVersion int `json:"schemaVersion"`
17+ MediaType string `json:"mediaType"`
18+ Config struct {
19+ MediaType string `json:"mediaType"`
20+ Digest string `json:"digest"`
21+ Size int64 `json:"size"`
22+ } `json:"config"`
23+}
24+
25+// ParseConfigDigest extracts the config digest from a manifest.
26+func ParseConfigDigest(manifestData []byte) (string, error) {
27+ var manifest manifestSchema
28+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
29+ return "", err
30+ }
31+
32+ return manifest.Config.Digest, nil
33+}
34+
35+// imageConfig represents the relevant parts of an OCI/Docker image config.
36+type imageConfig struct {
37+ Config struct {
38+ Labels map[string]string `json:"Labels"`
39+ } `json:"config"`
40+}
41+
42+// ParseImageLabels extracts Gordon labels from an image config blob.
43+func ParseImageLabels(configData []byte) (*domain.ImageLabels, error) {
44+ var config imageConfig
45+ if err := json.Unmarshal(configData, &config); err != nil {
46+ return nil, err
47+ }
48+
49+ labels := &domain.ImageLabels{}
50+
51+ if config.Config.Labels == nil {
52+ return labels, nil
53+ }
54+
55+ // Extract gordon.* labels
56+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
57+ labels.Domain = strings.TrimSpace(v)
58+ }
59+
60+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
61+ // Parse comma-separated domains
62+ for _, d := range strings.Split(v, ",") {
63+ d = strings.TrimSpace(d)
64+ if d != "" {
65+ labels.Domains = append(labels.Domains, d)
66+ }
67+ }
68+ }
69+
70+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
71+ labels.Health = strings.TrimSpace(v)
72+ }
73+
74+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
75+ if v, ok := config.Config.Labels[key]; ok {
76+ labels.Port = strings.TrimSpace(v)
77+ break
78+ }
79+ }
80+
81+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
82+ labels.EnvFile = strings.TrimSpace(v)
83+ }
84+
85+ return labels, nil
86+}
87+
88+// ExtractLabels extracts Gordon labels from an image manifest by fetching the
89+// config blob from blobStorage and parsing it. It is the shared implementation
90+// used by both AutoRouteHandler and AutoPreviewHandler.
91+func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.BlobStorage) (*domain.ImageLabels, error) {
92+ configDigest, err := ParseConfigDigest(manifestData)
93+ if err != nil {
94+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
95+ }
96+
97+ if configDigest == "" {
98+ return nil, fmt.Errorf("no config digest found in manifest")
99+ }
100+
101+ reader, err := blobStorage.GetBlob(configDigest)
102+ if err != nil {
103+ return nil, fmt.Errorf("failed to get config blob: %w", err)
104+ }
105+ defer reader.Close()
106+
107+ configData, err := io.ReadAll(reader)
108+ if err != nil {
109+ return nil, fmt.Errorf("failed to read config blob: %w", err)
110+ }
111+
112+ labels, err := ParseImageLabels(configData)
113+ if err != nil {
114+ return nil, fmt.Errorf("failed to parse config: %w", err)
115+ }
116+
117+ return labels, nil
118+}
@@ -0,0 +1,118 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "strings"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
15+type manifestSchema struct {
16+ SchemaVersion int `json:"schemaVersion"`
17+ MediaType string `json:"mediaType"`
18+ Config struct {
19+ MediaType string `json:"mediaType"`
20+ Digest string `json:"digest"`
21+ Size int64 `json:"size"`
22+ } `json:"config"`
23+}
24+
25+// ParseConfigDigest extracts the config digest from a manifest.
26+func ParseConfigDigest(manifestData []byte) (string, error) {
27+ var manifest manifestSchema
28+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
29+ return "", err
30+ }
31+
32+ return manifest.Config.Digest, nil
33+}
34+
35+// imageConfig represents the relevant parts of an OCI/Docker image config.
36+type imageConfig struct {
37+ Config struct {
38+ Labels map[string]string `json:"Labels"`
39+ } `json:"config"`
40+}
41+
42+// ParseImageLabels extracts Gordon labels from an image config blob.
43+func ParseImageLabels(configData []byte) (*domain.ImageLabels, error) {
44+ var config imageConfig
45+ if err := json.Unmarshal(configData, &config); err != nil {
46+ return nil, err
47+ }
48+
49+ labels := &domain.ImageLabels{}
50+
51+ if config.Config.Labels == nil {
52+ return labels, nil
53+ }
54+
55+ // Extract gordon.* labels
56+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
57+ labels.Domain = strings.TrimSpace(v)
58+ }
59+
60+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
61+ // Parse comma-separated domains
62+ for _, d := range strings.Split(v, ",") {
63+ d = strings.TrimSpace(d)
64+ if d != "" {
65+ labels.Domains = append(labels.Domains, d)
66+ }
67+ }
68+ }
69+
70+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
71+ labels.Health = strings.TrimSpace(v)
72+ }
73+
74+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
75+ if v, ok := config.Config.Labels[key]; ok {
76+ labels.Port = strings.TrimSpace(v)
77+ break
78+ }
79+ }
80+
81+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
82+ labels.EnvFile = strings.TrimSpace(v)
83+ }
84+
85+ return labels, nil
86+}
87+
88+// ExtractLabels extracts Gordon labels from an image manifest by fetching the
89+// config blob from blobStorage and parsing it. It is the shared implementation
90+// used by both AutoRouteHandler and AutoPreviewHandler.
91+func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.BlobStorage) (*domain.ImageLabels, error) {
92+ configDigest, err := ParseConfigDigest(manifestData)
93+ if err != nil {
94+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
95+ }
96+
97+ if configDigest == "" {
98+ return nil, fmt.Errorf("no config digest found in manifest")
99+ }
100+
101+ reader, err := blobStorage.GetBlob(configDigest)
102+ if err != nil {
103+ return nil, fmt.Errorf("failed to get config blob: %w", err)
104+ }
105+ defer reader.Close()
106+
107+ configData, err := io.ReadAll(reader)
108+ if err != nil {
109+ return nil, fmt.Errorf("failed to read config blob: %w", err)
110+ }
111+
112+ labels, err := ParseImageLabels(configData)
113+ if err != nil {
114+ return nil, fmt.Errorf("failed to parse config: %w", err)
115+ }
116+
117+ return labels, nil
118+}
A internal/usecase/auto/labels_test.go +274 -0
@@ -0,0 +1,274 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestParseConfigDigest(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ manifest map[string]any
21+ expected string
22+ wantErr bool
23+ }{
24+ {
25+ name: "valid manifest v2",
26+ manifest: map[string]any{
27+ "schemaVersion": 2,
28+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
29+ "config": map[string]any{
30+ "mediaType": "application/vnd.docker.container.image.v1+json",
31+ "digest": "sha256:abc123",
32+ "size": 1234,
33+ },
34+ },
35+ expected: "sha256:abc123",
36+ },
37+ {
38+ name: "OCI manifest",
39+ manifest: map[string]any{
40+ "schemaVersion": 2,
41+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
42+ "config": map[string]any{
43+ "mediaType": "application/vnd.oci.image.config.v1+json",
44+ "digest": "sha256:def456",
45+ "size": 5678,
46+ },
47+ },
48+ expected: "sha256:def456",
49+ },
50+ {
51+ name: "empty config digest",
52+ manifest: map[string]any{
53+ "schemaVersion": 2,
54+ "config": map[string]any{
55+ "digest": "",
56+ },
57+ },
58+ expected: "",
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ data, err := json.Marshal(tt.manifest)
65+ require.NoError(t, err)
66+
67+ result, err := ParseConfigDigest(data)
68+
69+ if tt.wantErr {
70+ assert.Error(t, err)
71+ return
72+ }
73+
74+ require.NoError(t, err)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
81+ _, err := ParseConfigDigest([]byte("invalid json"))
82+ assert.Error(t, err)
83+}
84+
85+func TestParseImageLabels(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ config map[string]any
89+ expected *domain.ImageLabels
90+ }{
91+ {
92+ name: "single domain label",
93+ config: map[string]any{
94+ "config": map[string]any{
95+ "Labels": map[string]any{
96+ "gordon.domain": "app.example.com",
97+ },
98+ },
99+ },
100+ expected: &domain.ImageLabels{
101+ Domain: "app.example.com",
102+ },
103+ },
104+ {
105+ name: "multiple domains label",
106+ config: map[string]any{
107+ "config": map[string]any{
108+ "Labels": map[string]any{
109+ "gordon.domains": "app1.example.com, app2.example.com",
110+ },
111+ },
112+ },
113+ expected: &domain.ImageLabels{
114+ Domains: []string{"app1.example.com", "app2.example.com"},
115+ },
116+ },
117+ {
118+ name: "all gordon labels",
119+ config: map[string]any{
120+ "config": map[string]any{
121+ "Labels": map[string]any{
122+ "gordon.domain": "app.example.com",
123+ "gordon.health": "/healthz",
124+ "gordon.proxy.port": "8080",
125+ "gordon.env-file": ".env.prod",
126+ },
127+ },
128+ },
129+ expected: &domain.ImageLabels{
130+ Domain: "app.example.com",
131+ Health: "/healthz",
132+ Port: "8080",
133+ EnvFile: ".env.prod",
134+ },
135+ },
136+ {
137+ name: "no labels",
138+ config: map[string]any{
139+ "config": map[string]any{
140+ "Labels": nil,
141+ },
142+ },
143+ expected: &domain.ImageLabels{},
144+ },
145+ {
146+ name: "no gordon labels",
147+ config: map[string]any{
148+ "config": map[string]any{
149+ "Labels": map[string]any{
150+ "other.label": "value",
151+ },
152+ },
153+ },
154+ expected: &domain.ImageLabels{},
155+ },
156+ {
157+ name: "whitespace in labels",
158+ config: map[string]any{
159+ "config": map[string]any{
160+ "Labels": map[string]any{
161+ "gordon.domain": " app.example.com ",
162+ },
163+ },
164+ },
165+ expected: &domain.ImageLabels{
166+ Domain: "app.example.com",
167+ },
168+ },
169+ {
170+ name: "deprecated port label fallback",
171+ config: map[string]any{
172+ "config": map[string]any{
173+ "Labels": map[string]any{
174+ "gordon.port": "9090",
175+ },
176+ },
177+ },
178+ expected: &domain.ImageLabels{
179+ Port: "9090",
180+ },
181+ },
182+ {
183+ name: "proxy.port takes precedence over port",
184+ config: map[string]any{
185+ "config": map[string]any{
186+ "Labels": map[string]any{
187+ "gordon.proxy.port": "8080",
188+ "gordon.port": "9090",
189+ },
190+ },
191+ },
192+ expected: &domain.ImageLabels{
193+ Port: "8080",
194+ },
195+ },
196+ }
197+
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ data, err := json.Marshal(tt.config)
201+ require.NoError(t, err)
202+
203+ result, err := ParseImageLabels(data)
204+ require.NoError(t, err)
205+
206+ assert.Equal(t, tt.expected.Domain, result.Domain)
207+ assert.Equal(t, tt.expected.Domains, result.Domains)
208+ assert.Equal(t, tt.expected.Health, result.Health)
209+ assert.Equal(t, tt.expected.Port, result.Port)
210+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
211+ })
212+ }
213+}
214+
215+func TestParseImageLabels_InvalidJSON(t *testing.T) {
216+ _, err := ParseImageLabels([]byte("invalid json"))
217+ assert.Error(t, err)
218+}
219+
220+func TestExtractLabels(t *testing.T) {
221+ ctx := context.Background()
222+
223+ manifest := map[string]any{
224+ "schemaVersion": 2,
225+ "config": map[string]any{
226+ "digest": "sha256:configdigest123",
227+ },
228+ }
229+ manifestData, _ := json.Marshal(manifest)
230+
231+ imageConf := map[string]any{
232+ "config": map[string]any{
233+ "Labels": map[string]any{
234+ "gordon.domain": "app.example.com",
235+ "gordon.health": "/healthz",
236+ },
237+ },
238+ }
239+ configData, _ := json.Marshal(imageConf)
240+
241+ blobStorage := mocks.NewMockBlobStorage(t)
242+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
243+
244+ labels, err := ExtractLabels(ctx, manifestData, blobStorage)
245+ require.NoError(t, err)
246+ assert.Equal(t, "app.example.com", labels.Domain)
247+ assert.Equal(t, "/healthz", labels.Health)
248+}
249+
250+func TestExtractLabels_InvalidManifest(t *testing.T) {
251+ ctx := context.Background()
252+ blobStorage := mocks.NewMockBlobStorage(t)
253+
254+ _, err := ExtractLabels(ctx, []byte("bad json"), blobStorage)
255+ assert.Error(t, err)
256+}
257+
258+func TestExtractLabels_EmptyConfigDigest(t *testing.T) {
259+ ctx := context.Background()
260+
261+ manifest := map[string]any{
262+ "schemaVersion": 2,
263+ "config": map[string]any{
264+ "digest": "",
265+ },
266+ }
267+ manifestData, _ := json.Marshal(manifest)
268+
269+ blobStorage := mocks.NewMockBlobStorage(t)
270+
271+ _, err := ExtractLabels(ctx, manifestData, blobStorage)
272+ assert.Error(t, err)
273+ assert.Contains(t, err.Error(), "no config digest")
274+}
@@ -0,0 +1,274 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestParseConfigDigest(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ manifest map[string]any
21+ expected string
22+ wantErr bool
23+ }{
24+ {
25+ name: "valid manifest v2",
26+ manifest: map[string]any{
27+ "schemaVersion": 2,
28+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
29+ "config": map[string]any{
30+ "mediaType": "application/vnd.docker.container.image.v1+json",
31+ "digest": "sha256:abc123",
32+ "size": 1234,
33+ },
34+ },
35+ expected: "sha256:abc123",
36+ },
37+ {
38+ name: "OCI manifest",
39+ manifest: map[string]any{
40+ "schemaVersion": 2,
41+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
42+ "config": map[string]any{
43+ "mediaType": "application/vnd.oci.image.config.v1+json",
44+ "digest": "sha256:def456",
45+ "size": 5678,
46+ },
47+ },
48+ expected: "sha256:def456",
49+ },
50+ {
51+ name: "empty config digest",
52+ manifest: map[string]any{
53+ "schemaVersion": 2,
54+ "config": map[string]any{
55+ "digest": "",
56+ },
57+ },
58+ expected: "",
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ data, err := json.Marshal(tt.manifest)
65+ require.NoError(t, err)
66+
67+ result, err := ParseConfigDigest(data)
68+
69+ if tt.wantErr {
70+ assert.Error(t, err)
71+ return
72+ }
73+
74+ require.NoError(t, err)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
81+ _, err := ParseConfigDigest([]byte("invalid json"))
82+ assert.Error(t, err)
83+}
84+
85+func TestParseImageLabels(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ config map[string]any
89+ expected *domain.ImageLabels
90+ }{
91+ {
92+ name: "single domain label",
93+ config: map[string]any{
94+ "config": map[string]any{
95+ "Labels": map[string]any{
96+ "gordon.domain": "app.example.com",
97+ },
98+ },
99+ },
100+ expected: &domain.ImageLabels{
101+ Domain: "app.example.com",
102+ },
103+ },
104+ {
105+ name: "multiple domains label",
106+ config: map[string]any{
107+ "config": map[string]any{
108+ "Labels": map[string]any{
109+ "gordon.domains": "app1.example.com, app2.example.com",
110+ },
111+ },
112+ },
113+ expected: &domain.ImageLabels{
114+ Domains: []string{"app1.example.com", "app2.example.com"},
115+ },
116+ },
117+ {
118+ name: "all gordon labels",
119+ config: map[string]any{
120+ "config": map[string]any{
121+ "Labels": map[string]any{
122+ "gordon.domain": "app.example.com",
123+ "gordon.health": "/healthz",
124+ "gordon.proxy.port": "8080",
125+ "gordon.env-file": ".env.prod",
126+ },
127+ },
128+ },
129+ expected: &domain.ImageLabels{
130+ Domain: "app.example.com",
131+ Health: "/healthz",
132+ Port: "8080",
133+ EnvFile: ".env.prod",
134+ },
135+ },
136+ {
137+ name: "no labels",
138+ config: map[string]any{
139+ "config": map[string]any{
140+ "Labels": nil,
141+ },
142+ },
143+ expected: &domain.ImageLabels{},
144+ },
145+ {
146+ name: "no gordon labels",
147+ config: map[string]any{
148+ "config": map[string]any{
149+ "Labels": map[string]any{
150+ "other.label": "value",
151+ },
152+ },
153+ },
154+ expected: &domain.ImageLabels{},
155+ },
156+ {
157+ name: "whitespace in labels",
158+ config: map[string]any{
159+ "config": map[string]any{
160+ "Labels": map[string]any{
161+ "gordon.domain": " app.example.com ",
162+ },
163+ },
164+ },
165+ expected: &domain.ImageLabels{
166+ Domain: "app.example.com",
167+ },
168+ },
169+ {
170+ name: "deprecated port label fallback",
171+ config: map[string]any{
172+ "config": map[string]any{
173+ "Labels": map[string]any{
174+ "gordon.port": "9090",
175+ },
176+ },
177+ },
178+ expected: &domain.ImageLabels{
179+ Port: "9090",
180+ },
181+ },
182+ {
183+ name: "proxy.port takes precedence over port",
184+ config: map[string]any{
185+ "config": map[string]any{
186+ "Labels": map[string]any{
187+ "gordon.proxy.port": "8080",
188+ "gordon.port": "9090",
189+ },
190+ },
191+ },
192+ expected: &domain.ImageLabels{
193+ Port: "8080",
194+ },
195+ },
196+ }
197+
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ data, err := json.Marshal(tt.config)
201+ require.NoError(t, err)
202+
203+ result, err := ParseImageLabels(data)
204+ require.NoError(t, err)
205+
206+ assert.Equal(t, tt.expected.Domain, result.Domain)
207+ assert.Equal(t, tt.expected.Domains, result.Domains)
208+ assert.Equal(t, tt.expected.Health, result.Health)
209+ assert.Equal(t, tt.expected.Port, result.Port)
210+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
211+ })
212+ }
213+}
214+
215+func TestParseImageLabels_InvalidJSON(t *testing.T) {
216+ _, err := ParseImageLabels([]byte("invalid json"))
217+ assert.Error(t, err)
218+}
219+
220+func TestExtractLabels(t *testing.T) {
221+ ctx := context.Background()
222+
223+ manifest := map[string]any{
224+ "schemaVersion": 2,
225+ "config": map[string]any{
226+ "digest": "sha256:configdigest123",
227+ },
228+ }
229+ manifestData, _ := json.Marshal(manifest)
230+
231+ imageConf := map[string]any{
232+ "config": map[string]any{
233+ "Labels": map[string]any{
234+ "gordon.domain": "app.example.com",
235+ "gordon.health": "/healthz",
236+ },
237+ },
238+ }
239+ configData, _ := json.Marshal(imageConf)
240+
241+ blobStorage := mocks.NewMockBlobStorage(t)
242+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
243+
244+ labels, err := ExtractLabels(ctx, manifestData, blobStorage)
245+ require.NoError(t, err)
246+ assert.Equal(t, "app.example.com", labels.Domain)
247+ assert.Equal(t, "/healthz", labels.Health)
248+}
249+
250+func TestExtractLabels_InvalidManifest(t *testing.T) {
251+ ctx := context.Background()
252+ blobStorage := mocks.NewMockBlobStorage(t)
253+
254+ _, err := ExtractLabels(ctx, []byte("bad json"), blobStorage)
255+ assert.Error(t, err)
256+}
257+
258+func TestExtractLabels_EmptyConfigDigest(t *testing.T) {
259+ ctx := context.Background()
260+
261+ manifest := map[string]any{
262+ "schemaVersion": 2,
263+ "config": map[string]any{
264+ "digest": "",
265+ },
266+ }
267+ manifestData, _ := json.Marshal(manifest)
268+
269+ blobStorage := mocks.NewMockBlobStorage(t)
270+
271+ _, err := ExtractLabels(ctx, manifestData, blobStorage)
272+ assert.Error(t, err)
273+ assert.Contains(t, err.Error(), "no config digest")
274+}
A internal/usecase/auto/validation.go +58 -0
@@ -0,0 +1,58 @@
1+package auto
2+
3+import "strings"
4+
5+// MatchesDomainAllowlist reports whether domain matches any of the given patterns.
6+// Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to
7+// allow everything. Wildcard patterns match a single subdomain level only, per
8+// DNS/TLS conventions.
9+func MatchesDomainAllowlist(domain string, patterns []string) bool {
10+ domain = strings.ToLower(domain)
11+ for _, pattern := range patterns {
12+ pattern = strings.ToLower(strings.TrimSpace(pattern))
13+ if pattern == "" {
14+ continue
15+ }
16+ if pattern == "*" {
17+ return true
18+ }
19+ if pattern == domain {
20+ return true
21+ }
22+ if !strings.HasPrefix(pattern, "*.") {
23+ continue
24+ }
25+ // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
26+ // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
27+ suffix := strings.TrimPrefix(pattern, "*.")
28+ if !strings.HasSuffix(domain, "."+suffix) {
29+ continue
30+ }
31+ prefix := strings.TrimSuffix(domain, "."+suffix)
32+ if prefix != "" && !strings.Contains(prefix, ".") {
33+ return true
34+ }
35+ }
36+ return false
37+}
38+
39+// ExtractRepoName strips the registry domain, tag, and digest from an image
40+// reference and returns the bare repository name (e.g. "org/myapp").
41+func ExtractRepoName(imageRef, registryDomain string) string {
42+ imageRef = strings.ToLower(imageRef)
43+ if idx := strings.Index(imageRef, "@"); idx != -1 {
44+ imageRef = imageRef[:idx]
45+ }
46+ if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
47+ slashIdx := strings.LastIndex(imageRef, "/")
48+ if idx > slashIdx {
49+ imageRef = imageRef[:idx]
50+ }
51+ }
52+ registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
53+ if registryPrefix != "" {
54+ prefix := registryPrefix + "/"
55+ imageRef = strings.TrimPrefix(imageRef, prefix)
56+ }
57+ return imageRef
58+}
@@ -0,0 +1,58 @@
1+package auto
2+
3+import "strings"
4+
5+// MatchesDomainAllowlist reports whether domain matches any of the given patterns.
6+// Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to
7+// allow everything. Wildcard patterns match a single subdomain level only, per
8+// DNS/TLS conventions.
9+func MatchesDomainAllowlist(domain string, patterns []string) bool {
10+ domain = strings.ToLower(domain)
11+ for _, pattern := range patterns {
12+ pattern = strings.ToLower(strings.TrimSpace(pattern))
13+ if pattern == "" {
14+ continue
15+ }
16+ if pattern == "*" {
17+ return true
18+ }
19+ if pattern == domain {
20+ return true
21+ }
22+ if !strings.HasPrefix(pattern, "*.") {
23+ continue
24+ }
25+ // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
26+ // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
27+ suffix := strings.TrimPrefix(pattern, "*.")
28+ if !strings.HasSuffix(domain, "."+suffix) {
29+ continue
30+ }
31+ prefix := strings.TrimSuffix(domain, "."+suffix)
32+ if prefix != "" && !strings.Contains(prefix, ".") {
33+ return true
34+ }
35+ }
36+ return false
37+}
38+
39+// ExtractRepoName strips the registry domain, tag, and digest from an image
40+// reference and returns the bare repository name (e.g. "org/myapp").
41+func ExtractRepoName(imageRef, registryDomain string) string {
42+ imageRef = strings.ToLower(imageRef)
43+ if idx := strings.Index(imageRef, "@"); idx != -1 {
44+ imageRef = imageRef[:idx]
45+ }
46+ if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
47+ slashIdx := strings.LastIndex(imageRef, "/")
48+ if idx > slashIdx {
49+ imageRef = imageRef[:idx]
50+ }
51+ }
52+ registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
53+ if registryPrefix != "" {
54+ prefix := registryPrefix + "/"
55+ imageRef = strings.TrimPrefix(imageRef, prefix)
56+ }
57+ return imageRef
58+}
A internal/usecase/auto/validation_test.go +59 -0
@@ -0,0 +1,59 @@
1+package auto
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestMatchesDomainAllowlist(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ domain string
13+ patterns []string
14+ want bool
15+ }{
16+ {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
17+ {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
18+ {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
19+ {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
20+ {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
21+ {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
22+ {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
23+ {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
24+ {name: "star allows all", domain: "anything.example.com", patterns: []string{"*"}, want: true},
25+ {name: "empty pattern skipped", domain: "app.example.com", patterns: []string{"", "app.example.com"}, want: true},
26+ {name: "whitespace trimmed in pattern", domain: "app.example.com", patterns: []string{" app.example.com "}, want: true},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ assert.Equal(t, tt.want, MatchesDomainAllowlist(tt.domain, tt.patterns))
32+ })
33+ }
34+}
35+
36+func TestExtractRepoName(t *testing.T) {
37+ tests := []struct {
38+ name string
39+ imageRef string
40+ registryDomain string
41+ want string
42+ }{
43+ {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
44+ {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
45+ {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
46+ {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
47+ {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
48+ {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
49+ {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
50+ {name: "no tag no digest", imageRef: "myapp", want: "myapp"},
51+ {name: "empty registry domain", imageRef: "myapp:latest", registryDomain: "", want: "myapp"},
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ assert.Equal(t, tt.want, ExtractRepoName(tt.imageRef, tt.registryDomain))
57+ })
58+ }
59+}
@@ -0,0 +1,59 @@
1+package auto
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestMatchesDomainAllowlist(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ domain string
13+ patterns []string
14+ want bool
15+ }{
16+ {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
17+ {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
18+ {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
19+ {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
20+ {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
21+ {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
22+ {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
23+ {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
24+ {name: "star allows all", domain: "anything.example.com", patterns: []string{"*"}, want: true},
25+ {name: "empty pattern skipped", domain: "app.example.com", patterns: []string{"", "app.example.com"}, want: true},
26+ {name: "whitespace trimmed in pattern", domain: "app.example.com", patterns: []string{" app.example.com "}, want: true},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ assert.Equal(t, tt.want, MatchesDomainAllowlist(tt.domain, tt.patterns))
32+ })
33+ }
34+}
35+
36+func TestExtractRepoName(t *testing.T) {
37+ tests := []struct {
38+ name string
39+ imageRef string
40+ registryDomain string
41+ want string
42+ }{
43+ {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
44+ {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
45+ {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
46+ {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
47+ {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
48+ {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
49+ {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
50+ {name: "no tag no digest", imageRef: "myapp", want: "myapp"},
51+ {name: "empty registry domain", imageRef: "myapp:latest", registryDomain: "", want: "myapp"},
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ assert.Equal(t, tt.want, ExtractRepoName(tt.imageRef, tt.registryDomain))
57+ })
58+ }
59+}
M internal/usecase/container/autoroute.go +7 -151
@@ -4,9 +4,7 @@ import (
44 "bufio"
55 "bytes"
66 "context"
7- "encoding/json"
87 "fmt"
9- "io"
108 "os"
119 "path/filepath"
1210 "sort"
@@ -17,6 +15,7 @@ import (
1715 "github.com/bnema/gordon/internal/boundaries/in"
1816 "github.com/bnema/gordon/internal/boundaries/out"
1917 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
2019 )
2120
2221 // EnvFileExtractor defines the interface for extracting env files from images.
@@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
205204 existingRoutes := h.configSvc.GetRoutes(ctx)
206205 for _, existing := range existingRoutes {
207206 if existing.Domain == routeDomain {
208- if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) {
207+ if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
209208 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210209 return false
211210 }
@@ -222,7 +221,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
222221 }
223222 }
224223
225- if !matchesDomainAllowlist(routeDomain, allowedDomains) {
224+ if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226225 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227226 return false
228227 }
@@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
239238 return false
240239 }
241240
242-func matchesDomainAllowlist(domain string, patterns []string) bool {
243- domain = strings.ToLower(domain)
244- for _, pattern := range patterns {
245- pattern = strings.ToLower(strings.TrimSpace(pattern))
246- if pattern == "" {
247- continue
248- }
249- if pattern == "*" {
250- return true
251- }
252- if pattern == domain {
253- return true
254- }
255- if !strings.HasPrefix(pattern, "*.") {
256- continue
257- }
258- // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
259- // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
260- suffix := strings.TrimPrefix(pattern, "*.")
261- if !strings.HasSuffix(domain, "."+suffix) {
262- continue
263- }
264- prefix := strings.TrimSuffix(domain, "."+suffix)
265- if prefix != "" && !strings.Contains(prefix, ".") {
266- return true
267- }
268- }
269- return false
270-}
271-
272-func extractRepoName(imageRef, registryDomain string) string {
273- imageRef = strings.ToLower(imageRef)
274- if idx := strings.Index(imageRef, "@"); idx != -1 {
275- imageRef = imageRef[:idx]
276- }
277- if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
278- slashIdx := strings.LastIndex(imageRef, "/")
279- if idx > slashIdx {
280- imageRef = imageRef[:idx]
281- }
282- }
283- registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
284- if registryPrefix != "" {
285- prefix := registryPrefix + "/"
286- imageRef = strings.TrimPrefix(imageRef, prefix)
287- }
288- return imageRef
289-}
290-
291241 // triggerDeploy initiates deployment for a route.
292242 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
293243 log := zerowrap.FromCtx(ctx)
@@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
389339 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
390340 log := zerowrap.FromCtx(ctx)
391341
392- // Parse manifest to get config digest
393- configDigest, err := parseConfigDigest(manifestData)
342+ labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
394343 if err != nil {
395- return nil, fmt.Errorf("failed to parse manifest: %w", err)
396- }
397-
398- if configDigest == "" {
399- return nil, fmt.Errorf("no config digest found in manifest")
400- }
401-
402- log.Debug().Str("config_digest", configDigest).Msg("found config digest")
403-
404- // Read config blob
405- reader, err := h.blobStorage.GetBlob(configDigest)
406- if err != nil {
407- return nil, fmt.Errorf("failed to get config blob: %w", err)
408- }
409- defer reader.Close()
410-
411- configData, err := io.ReadAll(reader)
412- if err != nil {
413- return nil, fmt.Errorf("failed to read config blob: %w", err)
414- }
415-
416- // Parse config to extract labels
417- labels, err := parseImageLabels(configData)
418- if err != nil {
419- return nil, fmt.Errorf("failed to parse config: %w", err)
420- }
421-
422- return labels, nil
423-}
424-
425-// manifestSchema represents the relevant parts of an OCI/Docker manifest.
426-type manifestSchema struct {
427- SchemaVersion int `json:"schemaVersion"`
428- MediaType string `json:"mediaType"`
429- Config struct {
430- MediaType string `json:"mediaType"`
431- Digest string `json:"digest"`
432- Size int64 `json:"size"`
433- } `json:"config"`
434-}
435-
436-// parseConfigDigest extracts the config digest from a manifest.
437-func parseConfigDigest(manifestData []byte) (string, error) {
438- var manifest manifestSchema
439- if err := json.Unmarshal(manifestData, &manifest); err != nil {
440- return "", err
441- }
442-
443- return manifest.Config.Digest, nil
444-}
445-
446-// imageConfig represents the relevant parts of an OCI/Docker image config.
447-type imageConfig struct {
448- Config struct {
449- Labels map[string]string `json:"Labels"`
450- } `json:"config"`
451-}
452-
453-// parseImageLabels extracts Gordon labels from an image config.
454-func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
455- var config imageConfig
456- if err := json.Unmarshal(configData, &config); err != nil {
457344 return nil, err
458345 }
459346
460- labels := &domain.ImageLabels{}
461-
462- if config.Config.Labels == nil {
463- return labels, nil
464- }
465-
466- // Extract gordon.* labels
467- if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
468- labels.Domain = strings.TrimSpace(v)
469- }
470-
471- if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
472- // Parse comma-separated domains
473- for _, d := range strings.Split(v, ",") {
474- d = strings.TrimSpace(d)
475- if d != "" {
476- labels.Domains = append(labels.Domains, d)
477- }
478- }
479- }
480-
481- if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
482- labels.Health = strings.TrimSpace(v)
483- }
484-
485- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
486- if v, ok := config.Config.Labels[key]; ok {
487- labels.Port = strings.TrimSpace(v)
488- break
489- }
490- }
491-
492- if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
493- labels.EnvFile = strings.TrimSpace(v)
347+ // Log the config digest for observability (best-effort parse for logging only)
348+ if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
349+ log.Debug().Str("config_digest", digest).Msg("found config digest")
494350 }
495351
496352 return labels, nil
@@ -4,9 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6 "context"6 "context"
7- "encoding/json"
8 "fmt"7 "fmt"
9- "io"
10 "os"8 "os"
11 "path/filepath"9 "path/filepath"
12 "sort"10 "sort"
@@ -17,6 +15,7 @@ import (
17 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
18 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
19 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
20 )19 )
21 20
22 // EnvFileExtractor defines the interface for extracting env files from images.21 // EnvFileExtractor defines the interface for extracting env files from images.
@@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
205 existingRoutes := h.configSvc.GetRoutes(ctx)204 existingRoutes := h.configSvc.GetRoutes(ctx)
206 for _, existing := range existingRoutes {205 for _, existing := range existingRoutes {
207 if existing.Domain == routeDomain {206 if existing.Domain == routeDomain {
208- if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) {207+ if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
209 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")208 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210 return false209 return false
211 }210 }
@@ -222,7 +221,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
222 }221 }
223 }222 }
224 223
225- if !matchesDomainAllowlist(routeDomain, allowedDomains) {224+ if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")225 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227 return false226 return false
228 }227 }
@@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
239 return false238 return false
240 }239 }
241 240
242-func matchesDomainAllowlist(domain string, patterns []string) bool {
243- domain = strings.ToLower(domain)
244- for _, pattern := range patterns {
245- pattern = strings.ToLower(strings.TrimSpace(pattern))
246- if pattern == "" {
247- continue
248- }
249- if pattern == "*" {
250- return true
251- }
252- if pattern == domain {
253- return true
254- }
255- if !strings.HasPrefix(pattern, "*.") {
256- continue
257- }
258- // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
259- // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
260- suffix := strings.TrimPrefix(pattern, "*.")
261- if !strings.HasSuffix(domain, "."+suffix) {
262- continue
263- }
264- prefix := strings.TrimSuffix(domain, "."+suffix)
265- if prefix != "" && !strings.Contains(prefix, ".") {
266- return true
267- }
268- }
269- return false
270-}
271-
272-func extractRepoName(imageRef, registryDomain string) string {
273- imageRef = strings.ToLower(imageRef)
274- if idx := strings.Index(imageRef, "@"); idx != -1 {
275- imageRef = imageRef[:idx]
276- }
277- if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
278- slashIdx := strings.LastIndex(imageRef, "/")
279- if idx > slashIdx {
280- imageRef = imageRef[:idx]
281- }
282- }
283- registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
284- if registryPrefix != "" {
285- prefix := registryPrefix + "/"
286- imageRef = strings.TrimPrefix(imageRef, prefix)
287- }
288- return imageRef
289-}
290-
291 // triggerDeploy initiates deployment for a route.241 // triggerDeploy initiates deployment for a route.
292 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {242 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
293 log := zerowrap.FromCtx(ctx)243 log := zerowrap.FromCtx(ctx)
@@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
389 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {339 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
390 log := zerowrap.FromCtx(ctx)340 log := zerowrap.FromCtx(ctx)
391 341
392- // Parse manifest to get config digest342+ labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
393- configDigest, err := parseConfigDigest(manifestData)
394 if err != nil {343 if err != nil {
395- return nil, fmt.Errorf("failed to parse manifest: %w", err)
396- }
397-
398- if configDigest == "" {
399- return nil, fmt.Errorf("no config digest found in manifest")
400- }
401-
402- log.Debug().Str("config_digest", configDigest).Msg("found config digest")
403-
404- // Read config blob
405- reader, err := h.blobStorage.GetBlob(configDigest)
406- if err != nil {
407- return nil, fmt.Errorf("failed to get config blob: %w", err)
408- }
409- defer reader.Close()
410-
411- configData, err := io.ReadAll(reader)
412- if err != nil {
413- return nil, fmt.Errorf("failed to read config blob: %w", err)
414- }
415-
416- // Parse config to extract labels
417- labels, err := parseImageLabels(configData)
418- if err != nil {
419- return nil, fmt.Errorf("failed to parse config: %w", err)
420- }
421-
422- return labels, nil
423-}
424-
425-// manifestSchema represents the relevant parts of an OCI/Docker manifest.
426-type manifestSchema struct {
427- SchemaVersion int `json:"schemaVersion"`
428- MediaType string `json:"mediaType"`
429- Config struct {
430- MediaType string `json:"mediaType"`
431- Digest string `json:"digest"`
432- Size int64 `json:"size"`
433- } `json:"config"`
434-}
435-
436-// parseConfigDigest extracts the config digest from a manifest.
437-func parseConfigDigest(manifestData []byte) (string, error) {
438- var manifest manifestSchema
439- if err := json.Unmarshal(manifestData, &manifest); err != nil {
440- return "", err
441- }
442-
443- return manifest.Config.Digest, nil
444-}
445-
446-// imageConfig represents the relevant parts of an OCI/Docker image config.
447-type imageConfig struct {
448- Config struct {
449- Labels map[string]string `json:"Labels"`
450- } `json:"config"`
451-}
452-
453-// parseImageLabels extracts Gordon labels from an image config.
454-func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
455- var config imageConfig
456- if err := json.Unmarshal(configData, &config); err != nil {
457 return nil, err344 return nil, err
458 }345 }
459 346
460- labels := &domain.ImageLabels{}347+ // Log the config digest for observability (best-effort parse for logging only)
461-348+ if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
462- if config.Config.Labels == nil {349+ log.Debug().Str("config_digest", digest).Msg("found config digest")
463- return labels, nil
464- }
465-
466- // Extract gordon.* labels
467- if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
468- labels.Domain = strings.TrimSpace(v)
469- }
470-
471- if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
472- // Parse comma-separated domains
473- for _, d := range strings.Split(v, ",") {
474- d = strings.TrimSpace(d)
475- if d != "" {
476- labels.Domains = append(labels.Domains, d)
477- }
478- }
479- }
480-
481- if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
482- labels.Health = strings.TrimSpace(v)
483- }
484-
485- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
486- if v, ok := config.Config.Labels[key]; ok {
487- labels.Port = strings.TrimSpace(v)
488- break
489- }
490- }
491-
492- if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
493- labels.EnvFile = strings.TrimSpace(v)
494 }350 }
495 351
496 return labels, nil352 return labels, nil
M internal/usecase/container/autoroute_test.go +10 -8
@@ -15,6 +15,7 @@ import (
1515 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1616 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
1819 )
1920
2021 // domainToEnvFileName tests
@@ -154,7 +155,8 @@ func TestParseEnvFile(t *testing.T) {
154155 }
155156 }
156157
157-// parseConfigDigest tests
158+// parseConfigDigest tests — now delegated to auto package; tested there directly.
159+// These tests call auto.ParseConfigDigest to maintain coverage for this package.
158160
159161 func TestParseConfigDigest(t *testing.T) {
160162 tests := []struct {
@@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) {
206208 data, err := json.Marshal(tt.manifest)
207209 require.NoError(t, err)
208210
209- result, err := parseConfigDigest(data)
211+ result, err := auto.ParseConfigDigest(data)
210212
211213 if tt.wantErr {
212214 assert.Error(t, err)
@@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) {
220222 }
221223
222224 func TestParseConfigDigest_InvalidJSON(t *testing.T) {
223- _, err := parseConfigDigest([]byte("invalid json"))
225+ _, err := auto.ParseConfigDigest([]byte("invalid json"))
224226 assert.Error(t, err)
225227 }
226228
227-// parseImageLabels tests
229+// parseImageLabels tests — now delegated to auto package; tested there directly.
228230
229231 func TestParseImageLabels(t *testing.T) {
230232 tests := []struct {
@@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) {
317319 data, err := json.Marshal(tt.config)
318320 require.NoError(t, err)
319321
320- result, err := parseImageLabels(data)
322+ result, err := auto.ParseImageLabels(data)
321323 require.NoError(t, err)
322324
323325 assert.Equal(t, tt.expected.Domain, result.Domain)
@@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) {
330332 }
331333
332334 func TestParseImageLabels_InvalidJSON(t *testing.T) {
333- _, err := parseImageLabels([]byte("invalid json"))
335+ _, err := auto.ParseImageLabels([]byte("invalid json"))
334336 assert.Error(t, err)
335337 }
336338
@@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) {
353355
354356 for _, tt := range tests {
355357 t.Run(tt.name, func(t *testing.T) {
356- assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns))
358+ assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
357359 })
358360 }
359361 }
@@ -376,7 +378,7 @@ func TestExtractRepoName(t *testing.T) {
376378
377379 for _, tt := range tests {
378380 t.Run(tt.name, func(t *testing.T) {
379- assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain))
381+ assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
380382 })
381383 }
382384 }
@@ -15,6 +15,7 @@ import (
15 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"15 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
18 )19 )
19 20
20 // domainToEnvFileName tests21 // domainToEnvFileName tests
@@ -154,7 +155,8 @@ func TestParseEnvFile(t *testing.T) {
154 }155 }
155 }156 }
156 157
157-// parseConfigDigest tests158+// parseConfigDigest tests — now delegated to auto package; tested there directly.
159+// These tests call auto.ParseConfigDigest to maintain coverage for this package.
158 160
159 func TestParseConfigDigest(t *testing.T) {161 func TestParseConfigDigest(t *testing.T) {
160 tests := []struct {162 tests := []struct {
@@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) {
206 data, err := json.Marshal(tt.manifest)208 data, err := json.Marshal(tt.manifest)
207 require.NoError(t, err)209 require.NoError(t, err)
208 210
209- result, err := parseConfigDigest(data)211+ result, err := auto.ParseConfigDigest(data)
210 212
211 if tt.wantErr {213 if tt.wantErr {
212 assert.Error(t, err)214 assert.Error(t, err)
@@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) {
220 }222 }
221 223
222 func TestParseConfigDigest_InvalidJSON(t *testing.T) {224 func TestParseConfigDigest_InvalidJSON(t *testing.T) {
223- _, err := parseConfigDigest([]byte("invalid json"))225+ _, err := auto.ParseConfigDigest([]byte("invalid json"))
224 assert.Error(t, err)226 assert.Error(t, err)
225 }227 }
226 228
227-// parseImageLabels tests229+// parseImageLabels tests — now delegated to auto package; tested there directly.
228 230
229 func TestParseImageLabels(t *testing.T) {231 func TestParseImageLabels(t *testing.T) {
230 tests := []struct {232 tests := []struct {
@@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) {
317 data, err := json.Marshal(tt.config)319 data, err := json.Marshal(tt.config)
318 require.NoError(t, err)320 require.NoError(t, err)
319 321
320- result, err := parseImageLabels(data)322+ result, err := auto.ParseImageLabels(data)
321 require.NoError(t, err)323 require.NoError(t, err)
322 324
323 assert.Equal(t, tt.expected.Domain, result.Domain)325 assert.Equal(t, tt.expected.Domain, result.Domain)
@@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) {
330 }332 }
331 333
332 func TestParseImageLabels_InvalidJSON(t *testing.T) {334 func TestParseImageLabels_InvalidJSON(t *testing.T) {
333- _, err := parseImageLabels([]byte("invalid json"))335+ _, err := auto.ParseImageLabels([]byte("invalid json"))
334 assert.Error(t, err)336 assert.Error(t, err)
335 }337 }
336 338
@@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) {
353 355
354 for _, tt := range tests {356 for _, tt := range tests {
355 t.Run(tt.name, func(t *testing.T) {357 t.Run(tt.name, func(t *testing.T) {
356- assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns))358+ assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
357 })359 })
358 }360 }
359 }361 }
@@ -376,7 +378,7 @@ func TestExtractRepoName(t *testing.T) {
376 378
377 for _, tt := range tests {379 for _, tt := range tests {
378 t.Run(tt.name, func(t *testing.T) {380 t.Run(tt.name, func(t *testing.T) {
379- assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain))381+ assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
380 })382 })
381 }383 }
382 }384 }

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