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().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
43 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
44 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
45 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{"web": {Container: "c-1"}}}, true, nil).Once()
46 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
47 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
48 WithProbeDeps(NewTestProbeDeps(runtime,
49 func(context.Context, string, string) (int, error) { return 500, nil },
50 func(context.Context, string) error { return nil },
51 ))
52
53 step := domain.AppOperationStep{ID: "service.web.start"}
54 result := &LifecycleResult{Services: map[string]ServiceResult{}}
55 svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
56
57 assert.Equal(t, domain.AppStepFailed, step.State)
58 assert.Equal(t, "failed", result.Services["web"].Result)
59 // One serialized withdrawal plus one state-only bind clear through
60 // the canonical boundary; the fail-closed ACTIVE write itself is
61 // covered by the publisher tests in internal/app.
62 assert.Equal(t, []string{"blog/web", "blog/web"}, traffic.withdrawn)
63 state.AssertNumberOfCalls(t, "SaveActive", 1)
64}
65
66// TestVerifyRunningService_ReadyPublishesBinds proves a ready generation is
67// published with its freshly inspected binds.
68func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
69 ctx := context.Background()
70 state := outmocks.NewMockAppState(t)
71 runtime := outmocks.NewMockContainerRuntime(t)
72 traffic := &stubTraffic{}
73 eff := domain.AppEffectiveService{Container: "c-1", EffectiveRevision: "rev-1", Spec: readySpec()}
74 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
75 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
76 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
77 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
78 App: "blog", Services: map[string]domain.AppEffectiveService{"web": eff},
79 }, true, nil).Once()
80 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
81
82 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
83 WithProbeDeps(NewTestProbeDeps(runtime,
84 func(context.Context, string, string) (int, error) { return 200, nil },
85 func(context.Context, string) error { return nil },
86 ))
87
88 step := domain.AppOperationStep{ID: "service.web.start"}
89 result := &LifecycleResult{Services: map[string]ServiceResult{}}
90 svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
91
92 assert.Equal(t, domain.AppStepSucceeded, step.State)
93 assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds)
94 assert.Equal(t, []string{"blog/web"}, traffic.withdrawn, "the service is withdrawn before re-verification")
95}
96
97// TestRedactDiagnostics_RemovesSecretValues proves a known secret value is
98// never persisted in failure diagnostics.
99func TestRedactDiagnostics_RemovesSecretValues(t *testing.T) {
100 state := outmocks.NewMockAppState(t)
101 secrets := outmocks.NewMockSecretProvider(t)
102 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
103 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/database-url").Return("supersecret", nil).Once()
104
105 svc := NewService(Deps{State: state, Secrets: secrets}, zerowrap.Default())
106 p := pinnedService{name: "web", spec: domain.AppService{
107 Name: "web", Secrets: map[string]string{"DATABASE_URL": "database-url"},
108 }}
109
110 got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"connect failed for supersecret", "still running"})
111
112 assert.Equal(t, []string{"connect failed for [redacted]", "still running"}, got)
113}
114
115// TestRedactDiagnostics_DropsWhenSecretUnreadable proves diagnostics are
116// dropped rather than persisted unredacted when a secret cannot be read.
117func TestRedactDiagnostics_DropsWhenSecretUnreadable(t *testing.T) {
118 state := outmocks.NewMockAppState(t)
119 secrets := outmocks.NewMockSecretProvider(t)
120 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
121 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/database-url").Return("", assert.AnError).Once()
122
123 svc := NewService(Deps{State: state, Secrets: secrets}, zerowrap.Default())
124 p := pinnedService{name: "web", spec: domain.AppService{
125 Name: "web", Secrets: map[string]string{"DATABASE_URL": "database-url"},
126 }}
127
128 assert.Nil(t, svc.redactDiagnostics(context.Background(), "blog", p, []string{"supersecret leaked"}))
129}
130
131// TestStopService_StopFailureSurfaces proves a runtime stop failure is
132// reported rather than recorded as a successful stop.
133func TestStopService_StopFailureSurfaces(t *testing.T) {
134 state := outmocks.NewMockAppState(t)
135 runtime := outmocks.NewMockContainerRuntime(t)
136 runtime.EXPECT().StopContainer(mock.Anything, "c-1", mock.Anything).Return(errors.New("cannot stop")).Once()
137
138 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{}}, zerowrap.Default())
139 eff := domain.AppEffectiveService{Container: "c-1", Spec: domain.AppService{Name: "web", StopGrace: time.Second}}
140 step, _, err := svc.stopService(context.Background(), "blog", "web", eff)
141
142 require.Error(t, err)
143 assert.Equal(t, domain.AppStepFailed, step.State)
144 assert.Contains(t, step.Error, "cannot stop")
145}
146
147// TestStopService_WithdrawFailureBlocksStop proves withdrawal runs first and
148// its failure stops the operation before the container is touched.
149func TestStopService_WithdrawFailureBlocksStop(t *testing.T) {
150 state := outmocks.NewMockAppState(t)
151 runtime := outmocks.NewMockContainerRuntime(t)
152
153 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{fail: true}}, zerowrap.Default())
154 eff := domain.AppEffectiveService{Container: "c-1", Spec: domain.AppService{Name: "web", StopGrace: time.Second}}
155 step, _, err := svc.stopService(context.Background(), "blog", "web", eff)
156
157 require.Error(t, err)
158 assert.Equal(t, domain.AppStepFailed, step.State)
159 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
160}