main bac21c6

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close