1package deployment_test
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/internal/usecase/deployment"
16)
17
18func testRevision(app string, services ...domain.AppService) domain.AppDesiredRevision {
19 for i := range services {
20 if services[i].StopGrace == 0 {
21 services[i].StopGrace = 10 * time.Second
22 }
23 if services[i].Readiness.Timeout == 0 {
24 services[i].Readiness.Timeout = 30 * time.Second
25 }
26 }
27 return domain.AppDesiredRevision{
28 Revision: "rev-1", App: app,
29 Spec: domain.AppSpec{Name: app, Env: map[string]string{}, Services: services},
30 }
31}
32
33func webService() domain.AppService {
34 return domain.AppService{
35 Name: "web", Image: "docker.io/example/web:1.4.2",
36 Readiness: domain.AppReadiness{Type: "http", Path: "/healthz"},
37 HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
38 Secrets: map[string]string{"DATABASE_URL": "database-url"},
39 }
40}
41
42func preflightService(
43 t *testing.T,
44 state *outmocks.MockAppState,
45 runtime *outmocks.MockContainerRuntime,
46 images *outmocks.MockImageResolver,
47 secrets *outmocks.MockSecretProvider,
48) *deployment.Service {
49 t.Helper()
50 // Every mutation claims a journal only when no other operation of the
51 // app is unfinished. These tests start clean.
52 state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything).
53 Return(domain.AppOperation{}, false, nil).Maybe()
54 return deployment.NewService(deployment.Deps{
55 State: state, Runtime: runtime, Images: images, Secrets: secrets,
56 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
57 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
58 func(context.Context, string, string) (int, error) { return 200, nil },
59 func(context.Context, string) error { return nil },
60 ))
61}
62
63func TestComputeOutcome_TerminalResults(t *testing.T) {
64 deployed := deployment.ServiceResult{Result: "deployed"}
65 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
66 assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed}))
67 assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": failed}))
68 assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed, "b": failed}))
69 assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{}))
70}
71
72// TestReconcileBoot_ContinuesAfterAppFailure proves R1: a failing app
73// never blocks verification of the following apps, and failures are
74// aggregated instead of aborting at the first one.
75func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
76 ctx := context.Background()
77 state := outmocks.NewMockAppState(t)
78 runtime := outmocks.NewMockContainerRuntime(t)
79 images := outmocks.NewMockImageResolver(t)
80 secrets := outmocks.NewMockSecretProvider(t)
81 svcSvc := preflightService(t, state, runtime, images, secrets)
82
83 badEff := domain.AppEffectiveService{
84 Container: "ctr-bad",
85 Spec: webService(),
86 BackendBinds: map[int]int{8080: 32768},
87 }
88 badActive := domain.AppActive{App: "bad", Services: map[string]domain.AppEffectiveService{"web": badEff}}
89 goodEff := domain.AppEffectiveService{
90 Container: "ctr-good",
91 Spec: webService(),
92 }
93 goodActive := domain.AppActive{App: "good", Services: map[string]domain.AppEffectiveService{"web": goodEff}}
94
95 state.EXPECT().Recover(mock.Anything).Return(nil)
96 state.EXPECT().ListApps(mock.Anything).Return([]string{"bad", "good"}, nil).Once()
97 // bad app: Start fails bind verification, binds withdrawn.
98 state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once()
99 state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil)
100 state.EXPECT().LoadLatestOperation(mock.Anything, "bad").Return(domain.AppOperation{}, false, nil).Maybe()
101 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
102 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
103 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
104 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-bad").Return(true, nil).Once()
105 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once()
106 // No traffic boundary is wired here, so the fail-closed bind
107 // withdrawal is a no-op and deployment writes no ACTIVE binds itself.
108 // good app: still verified after the bad one failed.
109 state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once()
110 // Start load + bind-refresh reload.
111 state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil)
112 state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
113 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
114 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
115 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
116 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
117 // Bind refresh persists the new bind (differs from recorded nil).
118 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
119
120 err := svcSvc.ReconcileBoot(ctx)
121 require.Error(t, err)
122 assert.Contains(t, err.Error(), `"bad"`)
123}