1package deployment
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/bnema/zerowrap"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18// readySpec is an HTTP service whose readiness fails fast.
19func readySpec() domain.AppService {
20 return domain.AppService{
21 Name: "web",
22 Image: "registry.example.com/blog/web:1.4.2",
23 HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
24 Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 50 * time.Millisecond},
25 }
26}
27
28// TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds proves a running
29// generation that fails readiness is withdrawn, never published, and its
30// recorded binds are cleared so the proxy cannot dial an unready port.
31func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
32 ctx := context.Background()
33 state := outmocks.NewMockAppState(t)
34 runtime := outmocks.NewMockContainerRuntime(t)
35 traffic := &stubTraffic{}
36 eff := domain.AppEffectiveService{
37 Container: "c-1",
38 EffectiveRevision: "rev-1",
39 Spec: readySpec(),
40 BackendBinds: map[int]int{8080: 32771},
41 }
42 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
43 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
44 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
45 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
46 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{"web": {Container: "c-1"}}}, true, nil).Once()
47 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
48 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
49 WithProbeDeps(NewTestProbeDeps(runtime,
50 func(context.Context, string, string) (int, error) { return 500, nil },
51 func(context.Context, string) error { return nil },
52 ))
53
54 step := domain.AppOperationStep{ID: "service.web.start"}
55 result := &LifecycleResult{Services: map[string]ServiceResult{}}
56 svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
57
58 assert.Equal(t, domain.AppStepFailed, step.State)
59 assert.Equal(t, "failed", result.Services["web"].Result)
60 // One serialized withdrawal plus one state-only bind clear through
61 // the canonical boundary; the fail-closed ACTIVE write itself is
62 // covered by the publisher tests in internal/app.
63 assert.Equal(t, []string{"blog/web", "blog/web"}, traffic.withdrawn)
64 state.AssertNumberOfCalls(t, "SaveActive", 1)
65}
66
67// TestVerifyRunningService_ReadyPublishesBinds proves a ready generation is
68// published with its freshly inspected binds.
69func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
70 ctx := context.Background()
71 state := outmocks.NewMockAppState(t)
72 runtime := outmocks.NewMockContainerRuntime(t)
73 traffic := &stubTraffic{}
74 eff := domain.AppEffectiveService{Container: "c-1", EffectiveRevision: "rev-1", Spec: readySpec()}
75 runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
76 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
77 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
78 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
79 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
80 App: "blog", Services: map[string]domain.AppEffectiveService{"web": eff},
81 }, true, nil).Once()
82 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
83
84 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
85 WithProbeDeps(NewTestProbeDeps(runtime,
86 func(context.Context, string, string) (int, error) { return 200, nil },
87 func(context.Context, string) error { return nil },
88 ))
89
90 step := domain.AppOperationStep{ID: "service.web.start"}
91 result := &LifecycleResult{Services: map[string]ServiceResult{}}
92 svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
93
94 assert.Equal(t, domain.AppStepSucceeded, step.State)
95 assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds)
96 assert.Equal(t, []string{"blog/web"}, traffic.withdrawn, "the service is withdrawn before re-verification")
97}
98
99// TestRedactDiagnostics_RemovesSecretValues proves a known secret value is
100// never persisted in failure diagnostics.
101func TestRedactDiagnostics_RemovesSecretValues(t *testing.T) {
102 state := outmocks.NewMockAppState(t)
103 secrets := outmocks.NewMockSecretProvider(t)
104 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
105 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/database-url").Return("supersecret", nil).Once()
106
107 svc := NewService(Deps{State: state, Secrets: secrets}, zerowrap.Default())
108 p := pinnedService{name: "web", spec: domain.AppService{
109 Name: "web", Secrets: map[string]string{"DATABASE_URL": "database-url"},
110 }}
111
112 got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"connect failed for supersecret", "still running"})
113
114 assert.Equal(t, []string{"connect failed for [redacted]", "still running"}, got)
115}
116
117// TestRedactDiagnostics_DropsWhenSecretUnreadable proves diagnostics are
118// dropped rather than persisted unredacted when a secret cannot be read.
119func TestRedactDiagnostics_DropsWhenSecretUnreadable(t *testing.T) {
120 state := outmocks.NewMockAppState(t)
121 secrets := outmocks.NewMockSecretProvider(t)
122 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
123 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/database-url").Return("", assert.AnError).Once()
124
125 svc := NewService(Deps{State: state, Secrets: secrets}, zerowrap.Default())
126 p := pinnedService{name: "web", spec: domain.AppService{
127 Name: "web", Secrets: map[string]string{"DATABASE_URL": "database-url"},
128 }}
129
130 assert.Nil(t, svc.redactDiagnostics(context.Background(), "blog", p, []string{"supersecret leaked"}))
131}
132
133// TestStopService_StopFailureSurfaces proves a runtime stop failure is
134// reported rather than recorded as a successful stop.
135func TestStopService_StopFailureSurfaces(t *testing.T) {
136 state := outmocks.NewMockAppState(t)
137 runtime := outmocks.NewMockContainerRuntime(t)
138 runtime.EXPECT().StopContainer(mock.Anything, "c-1", mock.Anything).Return(errors.New("cannot stop")).Once()
139
140 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{}}, zerowrap.Default())
141 eff := domain.AppEffectiveService{Container: "c-1", Spec: domain.AppService{Name: "web", StopGrace: time.Second}}
142 step, _, err := svc.stopService(context.Background(), "blog", "web", eff)
143
144 require.Error(t, err)
145 assert.Equal(t, domain.AppStepFailed, step.State)
146 assert.Contains(t, step.Error, "cannot stop")
147}
148
149// TestStopService_WithdrawFailureBlocksStop proves withdrawal runs first and
150// its failure stops the operation before the container is touched.
151func TestStopService_WithdrawFailureBlocksStop(t *testing.T) {
152 state := outmocks.NewMockAppState(t)
153 runtime := outmocks.NewMockContainerRuntime(t)
154
155 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{fail: true}}, zerowrap.Default())
156 eff := domain.AppEffectiveService{Container: "c-1", Spec: domain.AppService{Name: "web", StopGrace: time.Second}}
157 step, _, err := svc.stopService(context.Background(), "blog", "web", eff)
158
159 require.Error(t, err)
160 assert.Equal(t, domain.AppStepFailed, step.State)
161 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
162}