1package app
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 "github.com/bnema/gordon/internal/usecase/container"
17 "github.com/bnema/gordon/internal/usecase/deployment"
18)
19
20// daemonCtxKey tags the daemon/supervisor lifecycle context so a test can
21// prove the execution context descends from it and not from a request.
22type daemonCtxKey struct{}
23
24// fakeAppDeployEngine implements the deployment-engine port used by
25// newAppDaemonService. ExecuteDeploy reports the context it received and
26// blocks until that context ends, so cancellation is observable.
27type fakeAppDeployEngine struct {
28 executeStarted chan context.Context
29}
30
31func (f *fakeAppDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
32 return &deployment.StartDeployResult{
33 Owned: true,
34 Claim: deployment.DeployClaim{
35 App: "blog", Op: "op-1", Service: "web", Revision: "rev-1",
36 Journal: domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"},
37 },
38 }, nil
39}
40
41func (f *fakeAppDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
42 f.executeStarted <- ctx
43 <-ctx.Done()
44 return nil, ctx.Err()
45}
46
47func (f *fakeAppDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error {
48 return nil
49}
50
51func (f *fakeAppDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) {
52 return nil, nil
53}
54
55func (f *fakeAppDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) {
56 return nil, nil
57}
58
59func (f *fakeAppDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) {
60 return nil, nil
61}
62
63func (f *fakeAppDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) {
64 return nil, nil
65}
66
67// TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy proves the
68// bootstrap wiring used by initApps: the app service derives background
69// executions from the daemon/supervisor context, that context — not the
70// request context — owns the in-flight replacement, and daemon cancellation
71// reaches it.
72func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing.T) {
73 store := outmocks.NewMockAppState(t)
74 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").
75 Return(domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}, nil).Maybe()
76
77 engine := &fakeAppDeployEngine{executeStarted: make(chan context.Context, 1)}
78 daemonCtx, cancelDaemon := context.WithCancel(context.WithValue(context.Background(), daemonCtxKey{}, "daemon"))
79 svc := newAppDaemonService(daemonCtx, store, engine, nil, zerowrap.Default())
80 t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
81
82 requestCtx, cancelRequest := context.WithCancel(context.Background())
83 defer cancelRequest()
84
85 deployDone := make(chan struct{})
86 go func() {
87 defer close(deployDone)
88 _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", false, "key-1")
89 }()
90
91 var execCtx context.Context
92 select {
93 case execCtx = <-engine.executeStarted:
94 case <-time.After(3 * time.Second):
95 t.Fatal("background deploy execution never started")
96 }
97 assert.Equal(t, "daemon", execCtx.Value(daemonCtxKey{}), "execution must run on the daemon context")
98
99 // The request ending is unrelated to the daemon-owned execution.
100 cancelRequest()
101 select {
102 case <-execCtx.Done():
103 t.Fatal("request cancellation aborted the daemon-owned execution")
104 case <-time.After(50 * time.Millisecond):
105 }
106
107 cancelDaemon()
108 select {
109 case <-execCtx.Done():
110 case <-time.After(3 * time.Second):
111 t.Fatal("daemon context cancellation did not reach the in-flight execution")
112 }
113 <-deployDone
114}
115
116// orderRecordingAdmin records when app administration quiescence runs and
117// on which context, so shutdown ordering is observable.
118type orderRecordingAdmin struct {
119 order *[]string
120 deadline bool
121 err error
122}
123
124func (a *orderRecordingAdmin) Shutdown(ctx context.Context) error {
125 *a.order = append(*a.order, "app-shutdown")
126 _, a.deadline = ctx.Deadline()
127 return a.err
128}
129
130// TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce proves
131// the fail-closed shutdown ordering: when app administration cannot unwind on
132// the bounded shutdown context, the remaining state and runtime teardown is
133// skipped and the error is returned for the process to exit non-zero, so an
134// in-flight execution can never write to a closed store.
135func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *testing.T) {
136 // Keep internal-credential cleanup inside the test temp dir.
137 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
138
139 order := []string{}
140 admin := &orderRecordingAdmin{order: &order, err: errors.New("in-flight execution did not unwind")}
141
142 store := outmocks.NewMockAppState(t)
143
144 containerSvc := container.NewService(nil, nil, nil, container.Config{})
145 err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())
146
147 require.Error(t, err)
148 assert.Contains(t, err.Error(), "quiescence")
149 require.Equal(t, []string{"app-shutdown"}, order, "state teardown must not run under an unfinished execution")
150 store.AssertNotCalled(t, "Close")
151 assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context")
152}
153
154// TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState keeps the
155// normal ordering: app administration is cancelled and joined on the bounded
156// shutdown context before the app state store closes.
157func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing.T) {
158 // Keep internal-credential cleanup inside the test temp dir.
159 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
160
161 order := []string{}
162 admin := &orderRecordingAdmin{order: &order}
163
164 store := outmocks.NewMockAppState(t)
165 store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil)
166
167 containerSvc := container.NewService(nil, nil, nil, container.Config{})
168 require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()))
169
170 require.Equal(t, []string{"app-shutdown", "state-close"}, order)
171 assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context")
172}