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
81 release := make(chan struct{})
82 var releaseOnce sync.Once
83 unblock := func() { releaseOnce.Do(func() { close(release) }) }
84
85 engine := &blockingHandlerDeployEngine{started: make(chan context.Context, 1), release: release}
86 svc := apps.NewAppServiceImpl(store, engine, nil, zerowrap.Default()).WithDaemonContext(context.Background())
87 t.Cleanup(func() {
88 unblock()
89 _ = svc.Shutdown(context.Background())
90 })
91
92 handler := admin.NewHandler(admin.HandlerDeps{Log: zerowrap.Default(), AppSvc: svc})
93
94 reqCtx, cancelRequest := context.WithCancel(
95 context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:apps:write"}))
96 defer cancelRequest()
97 req := httptest.NewRequest(http.MethodPost, "/admin/apps/blog/deploy", nil)
98 req.Header.Set("Idempotency-Key", "key-1")
99 req = req.WithContext(reqCtx)
100 rec := httptest.NewRecorder()
101
102 serveDone := make(chan struct{})
103 go func() {
104 defer close(serveDone)
105 handler.ServeHTTP(rec, req)
106 }()
107
108 var execCtx context.Context
109 select {
110 case execCtx = <-engine.started:
111 case <-time.After(3 * time.Second):
112 t.Fatal("background deploy execution never started")
113 }
114 select {
115 case <-serveDone:
116 case <-time.After(3 * time.Second):
117 t.Fatal("handler did not answer promptly")
118 }
119 require.Equal(t, http.StatusAccepted, rec.Code)
120 // The persisted journal is what the handler answers with: the client can
121 // poll the same op through operations/by-key.
122 var resp dto.AppDeployResponse
123 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
124 assert.Equal(t, "op-1", resp.Op)
125 assert.Equal(t, dto.AppStatusRunning, resp.Status)
126
127 // The request is over and its context is cancelled: the daemon-owned
128 // execution must be unaffected.
129 cancelRequest()
130 select {
131 case <-execCtx.Done():
132 t.Fatal("request cancellation aborted the daemon-owned execution")
133 case <-time.After(50 * time.Millisecond):
134 }
135}