Skip to content

Commit 407ca02

Browse files
committed
feat(api): add multi-domain support with unified domain management
- Add DomainConfig type and Domains field to ServiceMetadata - Add GetDomains helper as single source of truth - Use GetDomains consistently in nginx and proxy managers - Remove duplicate legacy/multi-domain branching logic - Add rate limit support to multi-domain nginx templates - Add domain CRUD API endpoints (list, add, update, delete) - Support "default" ID for legacy networking.domain config - Add SSL manager support for multi-domain certificates - Add comprehensive domain API tests
1 parent de2d479 commit 407ca02

6 files changed

Lines changed: 1329 additions & 92 deletions

File tree

internal/api/domains_test.go

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/gin-gonic/gin"
13+
"gopkg.in/yaml.v3"
14+
15+
"github.com/flatrun/agent/internal/docker"
16+
"github.com/flatrun/agent/pkg/models"
17+
)
18+
19+
func setupDomainsTestServer(t *testing.T) (*Server, string, func()) {
20+
gin.SetMode(gin.TestMode)
21+
22+
tmpDir, err := os.MkdirTemp("", "domains-test-*")
23+
if err != nil {
24+
t.Fatalf("Failed to create temp dir: %v", err)
25+
}
26+
27+
manager := docker.NewManager(tmpDir)
28+
29+
server := &Server{
30+
manager: manager,
31+
}
32+
33+
cleanup := func() {
34+
os.RemoveAll(tmpDir)
35+
}
36+
37+
return server, tmpDir, cleanup
38+
}
39+
40+
func createTestDeployment(t *testing.T, basePath, name string, metadata *models.ServiceMetadata) {
41+
deployDir := filepath.Join(basePath, name)
42+
if err := os.MkdirAll(deployDir, 0755); err != nil {
43+
t.Fatalf("Failed to create deployment dir: %v", err)
44+
}
45+
46+
composeContent := `name: ` + name + `
47+
services:
48+
web:
49+
image: nginx:latest
50+
`
51+
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte(composeContent), 0644); err != nil {
52+
t.Fatalf("Failed to write compose file: %v", err)
53+
}
54+
55+
if metadata != nil {
56+
metadataBytes, err := yaml.Marshal(metadata)
57+
if err != nil {
58+
t.Fatalf("Failed to marshal metadata: %v", err)
59+
}
60+
if err := os.WriteFile(filepath.Join(deployDir, "service.yml"), metadataBytes, 0644); err != nil {
61+
t.Fatalf("Failed to write service.yml: %v", err)
62+
}
63+
}
64+
}
65+
66+
func TestListDomains(t *testing.T) {
67+
server, tmpDir, cleanup := setupDomainsTestServer(t)
68+
defer cleanup()
69+
70+
t.Run("returns empty array when no domains", func(t *testing.T) {
71+
createTestDeployment(t, tmpDir, "no-domains", &models.ServiceMetadata{
72+
Name: "no-domains",
73+
Type: "web",
74+
})
75+
76+
w := httptest.NewRecorder()
77+
c, _ := gin.CreateTestContext(w)
78+
c.Params = gin.Params{{Key: "name", Value: "no-domains"}}
79+
80+
server.listDomains(c)
81+
82+
if w.Code != http.StatusOK {
83+
t.Errorf("expected status 200, got %d", w.Code)
84+
}
85+
86+
var response map[string][]models.DomainConfig
87+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
88+
t.Fatalf("Failed to unmarshal response: %v", err)
89+
}
90+
91+
if len(response["domains"]) != 0 {
92+
t.Errorf("expected 0 domains, got %d", len(response["domains"]))
93+
}
94+
})
95+
96+
t.Run("returns legacy domain with default ID", func(t *testing.T) {
97+
createTestDeployment(t, tmpDir, "legacy-domain", &models.ServiceMetadata{
98+
Name: "legacy-domain",
99+
Type: "web",
100+
Networking: models.NetworkingConfig{
101+
Expose: true,
102+
Domain: "legacy.example.com",
103+
ContainerPort: 80,
104+
},
105+
SSL: models.SSLConfig{
106+
Enabled: true,
107+
AutoCert: true,
108+
},
109+
})
110+
111+
w := httptest.NewRecorder()
112+
c, _ := gin.CreateTestContext(w)
113+
c.Params = gin.Params{{Key: "name", Value: "legacy-domain"}}
114+
115+
server.listDomains(c)
116+
117+
if w.Code != http.StatusOK {
118+
t.Errorf("expected status 200, got %d", w.Code)
119+
}
120+
121+
var response map[string][]models.DomainConfig
122+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
123+
t.Fatalf("Failed to unmarshal response: %v", err)
124+
}
125+
126+
if len(response["domains"]) != 1 {
127+
t.Fatalf("expected 1 domain, got %d", len(response["domains"]))
128+
}
129+
130+
domain := response["domains"][0]
131+
if domain.ID != "default" {
132+
t.Errorf("expected ID 'default', got '%s'", domain.ID)
133+
}
134+
if domain.Domain != "legacy.example.com" {
135+
t.Errorf("expected domain 'legacy.example.com', got '%s'", domain.Domain)
136+
}
137+
})
138+
139+
t.Run("returns explicit domains", func(t *testing.T) {
140+
createTestDeployment(t, tmpDir, "explicit-domains", &models.ServiceMetadata{
141+
Name: "explicit-domains",
142+
Type: "web",
143+
Domains: []models.DomainConfig{
144+
{
145+
ID: "domain-1",
146+
Service: "web",
147+
ContainerPort: 80,
148+
Domain: "app.example.com",
149+
},
150+
{
151+
ID: "domain-2",
152+
Service: "api",
153+
ContainerPort: 8080,
154+
Domain: "api.example.com",
155+
},
156+
},
157+
})
158+
159+
w := httptest.NewRecorder()
160+
c, _ := gin.CreateTestContext(w)
161+
c.Params = gin.Params{{Key: "name", Value: "explicit-domains"}}
162+
163+
server.listDomains(c)
164+
165+
if w.Code != http.StatusOK {
166+
t.Errorf("expected status 200, got %d", w.Code)
167+
}
168+
169+
var response map[string][]models.DomainConfig
170+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
171+
t.Fatalf("Failed to unmarshal response: %v", err)
172+
}
173+
174+
if len(response["domains"]) != 2 {
175+
t.Fatalf("expected 2 domains, got %d", len(response["domains"]))
176+
}
177+
})
178+
}
179+
180+
func TestDeleteDomain(t *testing.T) {
181+
server, tmpDir, cleanup := setupDomainsTestServer(t)
182+
defer cleanup()
183+
184+
t.Run("deletes legacy domain with default ID", func(t *testing.T) {
185+
createTestDeployment(t, tmpDir, "delete-legacy", &models.ServiceMetadata{
186+
Name: "delete-legacy",
187+
Type: "web",
188+
Networking: models.NetworkingConfig{
189+
Expose: true,
190+
Domain: "legacy.example.com",
191+
ContainerPort: 80,
192+
},
193+
SSL: models.SSLConfig{
194+
Enabled: true,
195+
AutoCert: true,
196+
},
197+
})
198+
199+
w := httptest.NewRecorder()
200+
c, _ := gin.CreateTestContext(w)
201+
c.Params = gin.Params{
202+
{Key: "name", Value: "delete-legacy"},
203+
{Key: "domainId", Value: "default"},
204+
}
205+
206+
server.deleteDomain(c)
207+
208+
if w.Code != http.StatusOK {
209+
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
210+
}
211+
212+
// Verify the networking config was cleared
213+
metadataPath := filepath.Join(tmpDir, "delete-legacy", "service.yml")
214+
data, err := os.ReadFile(metadataPath)
215+
if err != nil {
216+
t.Fatalf("Failed to read metadata: %v", err)
217+
}
218+
var metadata models.ServiceMetadata
219+
if err := yaml.Unmarshal(data, &metadata); err != nil {
220+
t.Fatalf("Failed to unmarshal metadata: %v", err)
221+
}
222+
223+
if metadata.Networking.Expose {
224+
t.Error("expected Networking.Expose to be false")
225+
}
226+
if metadata.Networking.Domain != "" {
227+
t.Errorf("expected Networking.Domain to be empty, got '%s'", metadata.Networking.Domain)
228+
}
229+
})
230+
231+
t.Run("deletes explicit domain by ID", func(t *testing.T) {
232+
createTestDeployment(t, tmpDir, "delete-explicit", &models.ServiceMetadata{
233+
Name: "delete-explicit",
234+
Type: "web",
235+
Domains: []models.DomainConfig{
236+
{
237+
ID: "domain-1",
238+
Service: "web",
239+
ContainerPort: 80,
240+
Domain: "app.example.com",
241+
},
242+
{
243+
ID: "domain-2",
244+
Service: "api",
245+
ContainerPort: 8080,
246+
Domain: "api.example.com",
247+
},
248+
},
249+
})
250+
251+
w := httptest.NewRecorder()
252+
c, _ := gin.CreateTestContext(w)
253+
c.Params = gin.Params{
254+
{Key: "name", Value: "delete-explicit"},
255+
{Key: "domainId", Value: "domain-1"},
256+
}
257+
258+
server.deleteDomain(c)
259+
260+
if w.Code != http.StatusOK {
261+
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
262+
}
263+
264+
// Verify only domain-2 remains
265+
metadataPath := filepath.Join(tmpDir, "delete-explicit", "service.yml")
266+
data, err := os.ReadFile(metadataPath)
267+
if err != nil {
268+
t.Fatalf("Failed to read metadata: %v", err)
269+
}
270+
var metadata models.ServiceMetadata
271+
if err := yaml.Unmarshal(data, &metadata); err != nil {
272+
t.Fatalf("Failed to unmarshal metadata: %v", err)
273+
}
274+
275+
if len(metadata.Domains) != 1 {
276+
t.Fatalf("expected 1 domain remaining, got %d", len(metadata.Domains))
277+
}
278+
if metadata.Domains[0].ID != "domain-2" {
279+
t.Errorf("expected remaining domain ID 'domain-2', got '%s'", metadata.Domains[0].ID)
280+
}
281+
})
282+
283+
t.Run("returns 404 for non-existent domain", func(t *testing.T) {
284+
createTestDeployment(t, tmpDir, "no-such-domain", &models.ServiceMetadata{
285+
Name: "no-such-domain",
286+
Type: "web",
287+
Domains: []models.DomainConfig{
288+
{
289+
ID: "domain-1",
290+
Domain: "app.example.com",
291+
},
292+
},
293+
})
294+
295+
w := httptest.NewRecorder()
296+
c, _ := gin.CreateTestContext(w)
297+
c.Params = gin.Params{
298+
{Key: "name", Value: "no-such-domain"},
299+
{Key: "domainId", Value: "non-existent"},
300+
}
301+
302+
server.deleteDomain(c)
303+
304+
if w.Code != http.StatusNotFound {
305+
t.Errorf("expected status 404, got %d", w.Code)
306+
}
307+
})
308+
309+
t.Run("returns 404 for default ID when no legacy domain", func(t *testing.T) {
310+
createTestDeployment(t, tmpDir, "no-legacy", &models.ServiceMetadata{
311+
Name: "no-legacy",
312+
Type: "web",
313+
Networking: models.NetworkingConfig{
314+
Expose: false,
315+
Domain: "",
316+
},
317+
})
318+
319+
w := httptest.NewRecorder()
320+
c, _ := gin.CreateTestContext(w)
321+
c.Params = gin.Params{
322+
{Key: "name", Value: "no-legacy"},
323+
{Key: "domainId", Value: "default"},
324+
}
325+
326+
server.deleteDomain(c)
327+
328+
if w.Code != http.StatusNotFound {
329+
t.Errorf("expected status 404, got %d", w.Code)
330+
}
331+
})
332+
}
333+
334+
func TestAddDomain(t *testing.T) {
335+
server, tmpDir, cleanup := setupDomainsTestServer(t)
336+
defer cleanup()
337+
338+
t.Run("adds domain to deployment", func(t *testing.T) {
339+
createTestDeployment(t, tmpDir, "add-domain", &models.ServiceMetadata{
340+
Name: "add-domain",
341+
Type: "web",
342+
})
343+
344+
newDomain := models.DomainConfig{
345+
Service: "web",
346+
ContainerPort: 80,
347+
Domain: "new.example.com",
348+
SSL: models.SSLConfig{
349+
Enabled: true,
350+
AutoCert: true,
351+
},
352+
}
353+
body, _ := json.Marshal(newDomain)
354+
355+
w := httptest.NewRecorder()
356+
c, _ := gin.CreateTestContext(w)
357+
c.Params = gin.Params{{Key: "name", Value: "add-domain"}}
358+
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
359+
c.Request.Header.Set("Content-Type", "application/json")
360+
361+
server.addDomain(c)
362+
363+
// Check metadata was saved (proxy setup may fail without orchestrator)
364+
metadataPath := filepath.Join(tmpDir, "add-domain", "service.yml")
365+
data, err := os.ReadFile(metadataPath)
366+
if err != nil {
367+
t.Fatalf("Failed to read metadata: %v", err)
368+
}
369+
var metadata models.ServiceMetadata
370+
if err := yaml.Unmarshal(data, &metadata); err != nil {
371+
t.Fatalf("Failed to unmarshal metadata: %v", err)
372+
}
373+
374+
if len(metadata.Domains) != 1 {
375+
t.Fatalf("expected 1 domain, got %d", len(metadata.Domains))
376+
}
377+
if metadata.Domains[0].Domain != "new.example.com" {
378+
t.Errorf("expected domain 'new.example.com', got '%s'", metadata.Domains[0].Domain)
379+
}
380+
if metadata.Domains[0].ID == "" {
381+
t.Error("expected domain ID to be generated")
382+
}
383+
})
384+
}

0 commit comments

Comments
 (0)