1package deployment
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/zerowrap"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"
11
12 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16func networkTestService(
17 t *testing.T,
18 state *outmocks.MockAppState,
19 runtime *outmocks.MockContainerRuntime,
20 networks NetworkConfig,
21 limits ResourceLimits,
22) *Service {
23 t.Helper()
24 return NewService(Deps{State: state, Runtime: runtime, Networks: networks, Limits: limits}, zerowrap.Default())
25}
26
27// TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits proves the
28// created container joins the app's incarnation-owned network (never the
29// runtime default) and carries the configured resource limits.
30func TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits(t *testing.T) {
31 ctx := context.Background()
32 state := outmocks.NewMockAppState(t)
33 runtime := outmocks.NewMockContainerRuntime(t)
34
35 private := domain.AppPrivateNetworkName("gordon", "app-1")
36 state.EXPECT().LoadOwnership(mock.Anything, "blog").
37 Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
38 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
39 runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.MatchedBy(func(cfg domain.NetworkConfig) bool {
40 return cfg.Internal && domain.NetworkOwnedBy(cfg.Labels, domain.AppPrivateNetworkLabels("blog", "app-1"))
41 })).Return(nil).Once()
42 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
43 return cfg.NetworkMode == private &&
44 cfg.MemoryLimit == int64(1)<<30 &&
45 cfg.NanoCPUs == 2_000_000_000 &&
46 cfg.PidsLimit == 256 &&
47 assert.Equal(t, []string{"web"}, cfg.Aliases) &&
48 assert.Equal(t, "web", cfg.Hostname)
49 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
50 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
51 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
52
53 svc := networkTestService(t, state, runtime,
54 NetworkConfig{Prefix: "gordon", Internal: true},
55 ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256},
56 )
57 candidate, err := svc.createAndStart(ctx, "blog", "rev-1",
58 pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
59 require.NoError(t, err)
60 require.NotNil(t, candidate.Container)
61 assert.Empty(t, candidate.TCPBinds)
62 assert.Empty(t, candidate.UDPBinds)
63}
64
65// TestCreateAndStart_ReusesOwnedNetwork proves recovery reattaches the
66// already-owned incarnation network instead of creating or replacing it.
67func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) {
68 ctx := context.Background()
69 state := outmocks.NewMockAppState(t)
70 runtime := outmocks.NewMockContainerRuntime(t)
71
72 private := domain.AppPrivateNetworkName("gordon", "app-1")
73 state.EXPECT().LoadOwnership(mock.Anything, "blog").
74 Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
75 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
76 {Name: private, Labels: domain.AppPrivateNetworkLabels("blog", "app-1")},
77 }, nil).Once()
78 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
79 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
80 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
81 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
82
83 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
84 _, err := svc.createAndStart(ctx, "blog", "rev-1",
85 pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
86 require.NoError(t, err)
87}
88
89// TestCreateAndStart_RefusesForeignNetwork proves an unowned network with
90// the derived name fails closed before any container is created.
91func TestCreateAndStart_RefusesForeignNetwork(t *testing.T) {
92 ctx := context.Background()
93 state := outmocks.NewMockAppState(t)
94 runtime := outmocks.NewMockContainerRuntime(t)
95
96 private := domain.AppPrivateNetworkName("gordon", "app-1")
97 state.EXPECT().LoadOwnership(mock.Anything, "blog").
98 Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
99 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
100 {Name: private, Labels: map[string]string{domain.LabelManaged: "true"}},
101 }, nil).Once()
102
103 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
104 _, err := svc.createAndStart(ctx, "blog", "rev-1",
105 pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
106 require.ErrorIs(t, err, domain.ErrAppStateConflict)
107}
108
109// TestCreateAndStart_JoinsDeclaredSharedNetworks proves a service is
110// connected only to the shared networks its manifest declares.
111func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) {
112 ctx := context.Background()
113 state := outmocks.NewMockAppState(t)
114 runtime := outmocks.NewMockContainerRuntime(t)
115
116 private := domain.AppPrivateNetworkName("gordon", "app-1")
117 shared := domain.AppSharedNetworkName("gordon", "database")
118 state.EXPECT().LoadOwnership(mock.Anything, "blog").
119 Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
120 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
121 runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
122 runtime.EXPECT().CreateNetwork(mock.Anything, shared, mock.MatchedBy(func(cfg domain.NetworkConfig) bool {
123 return domain.NetworkOwnedBy(cfg.Labels, domain.AppSharedNetworkLabels("database"))
124 })).Return(nil).Once()
125 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
126 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
127 runtime.EXPECT().ConnectContainerToNetwork(mock.Anything, "c-1", shared).Return(nil).Once()
128 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
129 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
130
131 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
132 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
133 name: "web",
134 spec: domain.AppService{Name: "web"},
135 sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}},
136 }, "op-1", nil)
137 require.NoError(t, err)
138}
139
140// TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount proves a
141// manifest volume declared read-only reaches the runtime config as a
142// read-only mount instead of a writable one.
143func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T) {
144 ctx := context.Background()
145 state := outmocks.NewMockAppState(t)
146 runtime := outmocks.NewMockContainerRuntime(t)
147
148 private := domain.AppPrivateNetworkName("gordon", "app-1")
149 runtimeName := domain.RuntimeVolumeName("blog", "web", "config")
150 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil)
151 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
152 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
153 runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
154 runtime.EXPECT().CreateVolume(mock.Anything, runtimeName, mock.Anything).Return(nil).Once()
155 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
156 _, readOnly := cfg.ReadOnlyVolumes["/config"]
157 _, writable := cfg.Volumes["/config"]
158 return readOnly && !writable && cfg.ReadOnlyVolumes["/config"] == runtimeName
159 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
160 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
161 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
162
163 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
164 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
165 name: "web",
166 spec: domain.AppService{
167 Name: "web",
168 Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}},
169 },
170 }, "op-1", nil)
171 require.NoError(t, err)
172}
173
174// TestCreateAndStart_DistinctAppsNeverShareNetwork proves two apps receive
175// different incarnation networks, neither of which is a runtime default.
176func TestCreateAndStart_DistinctAppsNeverShareNetwork(t *testing.T) {
177 networks := map[string]string{}
178 for _, tc := range []struct{ app, id string }{{"blog", "app-1"}, {"shop", "app-2"}} {
179 private := domain.AppPrivateNetworkName("gordon", tc.id)
180 require.NotEqual(t, "default", private)
181 require.NotEqual(t, "bridge", private)
182 networks[tc.app] = private
183 }
184 assert.NotEqual(t, networks["blog"], networks["shop"])
185}