app_deploy_handler_lifecycle_test.go RawHistoryFolder
1package app
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "github.com/bnema/gordon/internal/adapters/dto"
18 "github.com/bnema/gordon/internal/adapters/in/http/admin"
19 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
20 "github.com/bnema/gordon/internal/domain"
21 "github.com/bnema/gordon/internal/usecase/apps"
22 "github.com/bnema/gordon/internal/usecase/deployment"
23)
24
25// blockingHandlerDeployEngine implements the deployment-engine port the
26// daemon-owned app service executes through. ExecuteDeploy reports the
27// context it received and blocks until released, so request-cancellation
28// independence is observable at the HTTP handler boundary.
29type blockingHandlerDeployEngine struct {
30 started chan context.Context
31 release chan struct{}
32}
33
34func (e *blockingHandlerDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
35 op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}
36 return &deployment.StartDeployResult{
37 Claim: deployment.DeployClaim{App: "blog", Op: "op-1", Service: "web", Revision: "rev-1", Journal: op},
38 Owned: true,
39 }, nil
40}
41
42func (e *blockingHandlerDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
43 e.started <- ctx
44 <-e.release
45 return &deployment.DeployResult{Op: "op-1", App: "blog"}, nil
46}
47
48func (e *blockingHandlerDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error {
49 return nil
50}
51
52func (e *blockingHandlerDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) {
53 return nil, nil
54}
55
56func (e *blockingHandlerDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) {
57 return nil, nil
58}
59
60func (e *blockingHandlerDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) {
61 return nil, nil
62}
63
64func (e *blockingHandlerDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) {
65 return nil, nil
66}
67
68// TestAppDeployHandler_RequestCancellationDoesNotAbortExecution drives the
69// admin deploy handler over the real daemon-owned AppServiceImpl and proves
70// the request boundary is inert: the handler answers 202 with the persisted
71// running journal promptly, and cancelling the request context afterwards
72// never reaches the in-flight replacement. The handler owns no goroutine; the
73// execution derives from the daemon context.
74func TestAppDeployHandler_RequestCancellationDoesNotAbortExecution(t *testing.T) {
75 store := outmocks.NewMockAppState(t)
76 running := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}
77 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once()
78 // Show resolves no app: the response carries the journal alone.
79 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
80 // The app-wide service scope check sees no services and lets it through.
81 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
82 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
83
84 release := make(chan struct{})
85 var releaseOnce sync.Once
86 unblock := func() { releaseOnce.Do(func() { close(release) }) }
87
88 engine := &blockingHandlerDeployEngine{started: make(chan context.Context, 1), release: release}
89 svc := apps.NewAppServiceImpl(store, engine, nil, zerowrap.Default()).WithDaemonContext(context.Background())
90 t.Cleanup(func() {
91 unblock()
92 _ = svc.Shutdown(context.Background())
93 })
94
95 handler := admin.NewHandler(admin.HandlerDeps{Log: zerowrap.Default(), AppSvc: svc})
96
97 reqCtx, cancelRequest := context.WithCancel(
98 context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:apps:write"}))
99 defer cancelRequest()
100 req := httptest.NewRequest(http.MethodPost, "/admin/apps/blog/deploy", nil)
101 req.Header.Set("Idempotency-Key", "key-1")
102 req = req.WithContext(reqCtx)
103 rec := httptest.NewRecorder()
104
105 serveDone := make(chan struct{})
106 go func() {
107 defer close(serveDone)
108 handler.ServeHTTP(rec, req)
109 }()
110
111 var execCtx context.Context
112 select {
113 case execCtx = <-engine.started:
114 case <-time.After(3 * time.Second):
115 t.Fatal("background deploy execution never started")
116 }
117 select {
118 case <-serveDone:
119 case <-time.After(3 * time.Second):
120 t.Fatal("handler did not answer promptly")
121 }
122 require.Equal(t, http.StatusAccepted, rec.Code)
123 // The persisted journal is what the handler answers with: the client can
124 // poll the same op through operations/by-key.
125 var resp dto.AppDeployResponse
126 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
127 assert.Equal(t, "op-1", resp.Op)
128 assert.Equal(t, dto.AppStatusRunning, resp.Status)
129
130 // The request is over and its context is cancelled: the daemon-owned
131 // execution must be unaffected.
132 cancelRequest()
133 select {
134 case <-execCtx.Done():
135 t.Fatal("request cancellation aborted the daemon-owned execution")
136 case <-time.After(50 * time.Millisecond):
137 }
138}