Skip to content

Commit 926011e

Browse files
committed
test(daemon): OnContainerStart/Die lifecycle, state management, teardown
1 parent 6a0f8f7 commit 926011e

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

internal/daemon/daemon_test.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/alysnnix/devproxy/internal/dns"
9+
"github.com/alysnnix/devproxy/internal/forwarder"
10+
"github.com/alysnnix/devproxy/internal/ipman"
11+
"github.com/alysnnix/devproxy/internal/state"
12+
"github.com/alysnnix/devproxy/internal/watcher"
13+
)
14+
15+
// newTestDaemon creates a Daemon suitable for unit testing.
16+
// It uses in-memory state (no persistence directory), a real IPManager,
17+
// and a DNS server bound to a random port on 127.0.0.1.
18+
func newTestDaemon(t *testing.T) *Daemon {
19+
t.Helper()
20+
21+
s := state.New("")
22+
im := ipman.New(s)
23+
dnsServer := dns.New("127.0.0.1:0")
24+
25+
go dnsServer.ListenAndServe()
26+
// Give the DNS server a moment to bind.
27+
time.Sleep(50 * time.Millisecond)
28+
29+
t.Cleanup(func() {
30+
dnsServer.Shutdown()
31+
})
32+
33+
return &Daemon{
34+
state: s,
35+
ipman: im,
36+
dns: dnsServer,
37+
forwarders: make(map[string][]*forwarder.Forwarder),
38+
cancelFns: make(map[string]context.CancelFunc),
39+
}
40+
}
41+
42+
// seedProject pre-populates the daemon's state and DNS with a project so
43+
// that OnContainerStart takes the "existing project" code path, which
44+
// avoids the AddLoopbackIP call that requires CAP_NET_ADMIN.
45+
func seedProject(d *Daemon, name, ip string) {
46+
d.state.AddProject(&state.Project{Name: name, IP: ip})
47+
d.dns.Register(name, ip)
48+
}
49+
50+
func TestOnContainerStartAddsProject(t *testing.T) {
51+
d := newTestDaemon(t)
52+
53+
project := "myapp"
54+
ip := d.ipman.HashIP(project)
55+
56+
// Seed the project so we skip the AddLoopbackIP call.
57+
seedProject(d, project, ip)
58+
59+
ctx := context.Background()
60+
info := watcher.ContainerInfo{
61+
Project: project,
62+
Service: "web",
63+
Ports: map[int]int{8080: 32768, 443: 32769},
64+
}
65+
66+
if err := d.OnContainerStart(ctx, info); err != nil {
67+
t.Fatalf("OnContainerStart returned error: %v", err)
68+
}
69+
70+
// Verify the project exists in state with the correct IP.
71+
p := d.state.GetProject(project)
72+
if p == nil {
73+
t.Fatal("expected project in state, got nil")
74+
}
75+
if p.IP != ip {
76+
t.Errorf("expected IP %s, got %s", ip, p.IP)
77+
}
78+
79+
// Verify port mappings were recorded.
80+
if len(p.Ports) != 2 {
81+
t.Fatalf("expected 2 port mappings, got %d", len(p.Ports))
82+
}
83+
84+
// Build a lookup map for easier assertion.
85+
portMap := make(map[int]int)
86+
for _, pm := range p.Ports {
87+
portMap[pm.ContainerPort] = pm.HostPort
88+
}
89+
if portMap[8080] != 32768 {
90+
t.Errorf("expected container port 8080 -> host 32768, got %d", portMap[8080])
91+
}
92+
if portMap[443] != 32769 {
93+
t.Errorf("expected container port 443 -> host 32769, got %d", portMap[443])
94+
}
95+
96+
// Verify internal forwarder tracking.
97+
if len(d.forwarders[project]) != 2 {
98+
t.Errorf("expected 2 forwarders, got %d", len(d.forwarders[project]))
99+
}
100+
}
101+
102+
func TestOnContainerDieRemovesProject(t *testing.T) {
103+
d := newTestDaemon(t)
104+
105+
project := "myapp"
106+
ip := d.ipman.HashIP(project)
107+
108+
// Seed and start.
109+
seedProject(d, project, ip)
110+
111+
ctx := context.Background()
112+
info := watcher.ContainerInfo{
113+
Project: project,
114+
Service: "web",
115+
Ports: map[int]int{8080: 32768},
116+
}
117+
118+
if err := d.OnContainerStart(ctx, info); err != nil {
119+
t.Fatalf("OnContainerStart returned error: %v", err)
120+
}
121+
122+
// Sanity check: project exists.
123+
if d.state.GetProject(project) == nil {
124+
t.Fatal("project should exist after OnContainerStart")
125+
}
126+
127+
// Die.
128+
dieInfo := watcher.ContainerInfo{
129+
Project: project,
130+
Service: "web",
131+
}
132+
if err := d.OnContainerDie(ctx, dieInfo); err != nil {
133+
t.Fatalf("OnContainerDie returned error: %v", err)
134+
}
135+
136+
// Verify project is gone from state.
137+
if d.state.GetProject(project) != nil {
138+
t.Error("expected project to be removed from state after OnContainerDie")
139+
}
140+
141+
// Verify forwarders and cancel functions are cleaned up.
142+
if len(d.forwarders[project]) != 0 {
143+
t.Errorf("expected forwarders to be cleared, got %d", len(d.forwarders[project]))
144+
}
145+
146+
cancelCount := 0
147+
for key := range d.cancelFns {
148+
if len(key) > len(project) && key[:len(project)+1] == project+":" {
149+
cancelCount++
150+
}
151+
}
152+
if cancelCount != 0 {
153+
t.Errorf("expected 0 cancel functions for project, got %d", cancelCount)
154+
}
155+
}
156+
157+
func TestOnContainerStartExistingProjectReusesIP(t *testing.T) {
158+
d := newTestDaemon(t)
159+
160+
project := "myapp"
161+
ip := d.ipman.HashIP(project)
162+
163+
// Seed the project.
164+
seedProject(d, project, ip)
165+
166+
ctx := context.Background()
167+
168+
// First container start (service "web").
169+
info1 := watcher.ContainerInfo{
170+
Project: project,
171+
Service: "web",
172+
Ports: map[int]int{8080: 32768},
173+
}
174+
if err := d.OnContainerStart(ctx, info1); err != nil {
175+
t.Fatalf("first OnContainerStart returned error: %v", err)
176+
}
177+
178+
ipAfterFirst := d.state.GetProject(project).IP
179+
180+
// Second container start (service "api") for the same project.
181+
info2 := watcher.ContainerInfo{
182+
Project: project,
183+
Service: "api",
184+
Ports: map[int]int{3000: 32770},
185+
}
186+
if err := d.OnContainerStart(ctx, info2); err != nil {
187+
t.Fatalf("second OnContainerStart returned error: %v", err)
188+
}
189+
190+
ipAfterSecond := d.state.GetProject(project).IP
191+
192+
if ipAfterFirst != ipAfterSecond {
193+
t.Errorf("expected same IP across container starts, got %s then %s", ipAfterFirst, ipAfterSecond)
194+
}
195+
if ipAfterSecond != ip {
196+
t.Errorf("expected IP %s, got %s", ip, ipAfterSecond)
197+
}
198+
}
199+
200+
func TestTeardownAllClearsState(t *testing.T) {
201+
d := newTestDaemon(t)
202+
ctx := context.Background()
203+
204+
projects := []struct {
205+
name string
206+
port int
207+
host int
208+
}{
209+
{"alpha", 8080, 32768},
210+
{"bravo", 3000, 32769},
211+
{"charlie", 443, 32770},
212+
}
213+
214+
for _, p := range projects {
215+
ip := d.ipman.HashIP(p.name)
216+
seedProject(d, p.name, ip)
217+
218+
info := watcher.ContainerInfo{
219+
Project: p.name,
220+
Service: "web",
221+
Ports: map[int]int{p.port: p.host},
222+
}
223+
if err := d.OnContainerStart(ctx, info); err != nil {
224+
t.Fatalf("OnContainerStart(%s) returned error: %v", p.name, err)
225+
}
226+
}
227+
228+
// Verify all projects are in state.
229+
if len(d.state.ListProjects()) != 3 {
230+
t.Fatalf("expected 3 projects, got %d", len(d.state.ListProjects()))
231+
}
232+
233+
// Teardown everything.
234+
d.teardownAll()
235+
236+
// Verify state is completely clean.
237+
if len(d.state.ListProjects()) != 0 {
238+
t.Errorf("expected 0 projects after teardownAll, got %d", len(d.state.ListProjects()))
239+
}
240+
241+
if len(d.forwarders) != 0 {
242+
t.Errorf("expected 0 forwarder entries after teardownAll, got %d", len(d.forwarders))
243+
}
244+
245+
if len(d.cancelFns) != 0 {
246+
t.Errorf("expected 0 cancel functions after teardownAll, got %d", len(d.cancelFns))
247+
}
248+
}

0 commit comments

Comments
 (0)