carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package instancectl
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "testing"
14
15 "github.com/carlosframework/platform/internal/cgroup"
16 "github.com/carlosframework/platform/internal/provision"
17)
18
19func legacyTier() string { return strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10) }
20
21func legacyClaimFixture(t *testing.T) (*Server, *unitCtl, string, string, string) {
22 t.Helper()
23 u, ctl, in, _ := claimUnitFixture(t)
24 u.UnitRoot, u.DataRoot = t.TempDir(), t.TempDir()
25 if err := os.Chmod(u.UnitRoot, 0700); err != nil {
26 t.Fatal(err)
27 }
28 if err := u.freezeLaunch(t.Context(), in.Host, Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/opt/carlos/app"}}); err != nil {
29 t.Fatal(err)
30 }
31 source, dropin, err := u.launchPaths(in.Host)
32 if err != nil {
33 t.Fatal(err)
34 }
35 ctl.calls = nil
36 s := &Server{
37 Systemctl: ctl.run,
38 CgroupRoot: u.CgroupRoot,
39 ExeRoot: u.ExeRoot,
40 DropInDir: u.UnitRoot,
41 DataRoot: u.DataRoot,
42 cgroupWaitBudget: u.WaitBudget,
43 }
44 return s, ctl, in.Host, filepath.Join(source, "launch.json"), filepath.Join(dropin, "60-claim-launch.conf")
45}
46
47func legacyRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
48 t.Helper()
49 rec := httptest.NewRecorder()
50 s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil))
51 return rec
52}
53
54func TestRev3ClaimOnFlagOffLegacyStart(t *testing.T) {
55 u, ctl, in, _ := claimUnitFixture(t)
56 u.UnitRoot, u.DataRoot = t.TempDir(), t.TempDir()
57 if err := os.Chmod(u.UnitRoot, 0700); err != nil {
58 t.Fatal(err)
59 }
60 old := Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/old/app", "old-argument"}, Env: []string{"VERSION=old"}}
61 if err := u.freezeLaunch(t.Context(), in.Host, old); err != nil {
62 t.Fatal(err)
63 }
64 source, dropinDir, err := u.launchPaths(in.Host)
65 if err != nil {
66 t.Fatal(err)
67 }
68 snapshot := filepath.Join(source, "launch.json")
69 dropin := filepath.Join(dropinDir, "60-claim-launch.conf")
70
71 // The claim-on server has an active invocation when the feature flag is
72 // turned off and instance-control is reconstructed without ClaimStop.
73 ctl.state = "active"
74 populate(t, ctl.events, true)
75 newLaunch := Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/new/app", "new-argument"}, Env: []string{"VERSION=new"}}
76 raw, err := json.Marshal(newLaunch)
77 if err != nil {
78 t.Fatal(err)
79 }
80 instanceDir := provision.InstanceDir(u.DataRoot, in.Host)
81 if err := os.MkdirAll(instanceDir, 0755); err != nil {
82 t.Fatal(err)
83 }
84 mutable := filepath.Join(instanceDir, LaunchFileName(RoleRun))
85 if err := os.WriteFile(mutable, raw, 0644); err != nil {
86 t.Fatal(err)
87 }
88
89 var provedStopped bool
90 var consumed Launch
91 systemctl := func(ctx context.Context, args ...string) (string, error) {
92 if args[0] == "start" {
93 launchPath := mutable
94 if _, err := os.Stat(dropin); err == nil {
95 launchPath = snapshot
96 }
97 body, err := os.ReadFile(launchPath)
98 if err != nil {
99 return "", err
100 }
101 if err := json.Unmarshal(body, &consumed); err != nil {
102 return "", err
103 }
104 }
105 out, err := ctl.run(ctx, args...)
106 if args[0] == "show" && strings.Contains(args[2], "ActiveState") {
107 for _, path := range []string{snapshot, dropin} {
108 if _, statErr := os.Stat(path); statErr != nil {
109 t.Fatalf("artifact removed before stopped-state proof: %s: %v", path, statErr)
110 }
111 }
112 provedStopped = strings.Contains(out, "ActiveState=inactive")
113 }
114 return out, err
115 }
116 s := &Server{
117 ClaimStop: nil,
118 Systemctl: systemctl,
119 CgroupRoot: u.CgroupRoot,
120 ExeRoot: u.ExeRoot,
121 DropInDir: u.UnitRoot,
122 DataRoot: u.DataRoot,
123 }
124 if rec := legacyRequest(t, s, "/stop?host="+in.Host+"&role=run"); rec.Code != http.StatusNoContent {
125 t.Fatalf("stop=%d: %s", rec.Code, rec.Body.String())
126 }
127 if !provedStopped {
128 t.Fatal("legacy stop did not prove the claim invocation inactive")
129 }
130 for _, path := range []string{snapshot, dropin} {
131 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
132 t.Fatalf("old claim artifact survived proved stop: %s: %v", path, err)
133 }
134 }
135 if rec := legacyRequest(t, s, "/start?host="+in.Host+"&role=run&memory_max="+legacyTier()); rec.Code != http.StatusOK {
136 t.Fatalf("start=%d: %s", rec.Code, rec.Body.String())
137 }
138 if strings.Join(consumed.Argv, " ") != strings.Join(newLaunch.Argv, " ") || strings.Join(consumed.Env, " ") != strings.Join(newLaunch.Env, " ") {
139 t.Fatalf("legacy start consumed %+v, want new mutable launch %+v", consumed, newLaunch)
140 }
141}
142
143func TestRev3LegacyStartClearsStoppedClaimLaunchBeforeStarting(t *testing.T) {
144 s, ctl, host, snapshot, dropin := legacyClaimFixture(t)
145 rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier())
146 if rec.Code != http.StatusOK {
147 t.Fatalf("start=%d: %s", rec.Code, rec.Body.String())
148 }
149 for _, path := range []string{snapshot, dropin} {
150 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
151 t.Fatalf("claim artifact survived legacy start: %s: %v", path, err)
152 }
153 }
154 calls := ctl.joined()
155 if proof, reload, start := strings.Index(calls, "show -p ActiveState"), strings.Index(calls, "daemon-reload"), strings.Index(calls, "start "); proof < 0 || reload < proof || start < reload {
156 t.Fatalf("cleanup did not precede start:\n%s", calls)
157 }
158}
159
160func TestRev3LegacyClaimCleanupFailsClosed(t *testing.T) {
161 for _, orphan := range []string{"permit.json", "exec.sock"} {
162 t.Run("orphaned "+orphan, func(t *testing.T) {
163 s, ctl, host, snapshot, dropin := legacyClaimFixture(t)
164 source := filepath.Dir(snapshot)
165 for _, path := range []string{snapshot, dropin} {
166 if err := os.Remove(path); err != nil {
167 t.Fatal(err)
168 }
169 }
170 orphanPath := filepath.Join(source, orphan)
171 if err := os.WriteFile(orphanPath, []byte("claim boundary"), 0600); err != nil {
172 t.Fatal(err)
173 }
174 ctl.state = "active"
175 populate(t, ctl.events, true)
176 rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier())
177 if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") {
178 t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined())
179 }
180 if !strings.Contains(ctl.joined(), "show -p ActiveState") {
181 t.Fatalf("orphan did not force stop proof:\n%s", ctl.joined())
182 }
183 if _, err := os.Stat(orphanPath); err != nil {
184 t.Fatalf("live orphan removed: %v", err)
185 }
186 })
187 }
188
189 t.Run("live unit", func(t *testing.T) {
190 s, ctl, host, snapshot, dropin := legacyClaimFixture(t)
191 ctl.state = "active"
192 populate(t, ctl.events, true)
193 rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier())
194 if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") {
195 t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined())
196 }
197 for _, path := range []string{snapshot, dropin} {
198 if _, err := os.Stat(path); err != nil {
199 t.Fatalf("live artifact removed: %s: %v", path, err)
200 }
201 }
202 })
203
204 t.Run("proof error after stop", func(t *testing.T) {
205 s, ctl, host, snapshot, dropin := legacyClaimFixture(t)
206 s.Systemctl = func(ctx context.Context, args ...string) (string, error) {
207 if args[0] == "show" {
208 return "", errors.New("bus gone")
209 }
210 return ctl.run(ctx, args...)
211 }
212 rec := legacyRequest(t, s, "/stop?host="+host+"&role=run")
213 if rec.Code != http.StatusBadGateway {
214 t.Fatalf("stop=%d: %s", rec.Code, rec.Body.String())
215 }
216 for _, path := range []string{snapshot, dropin} {
217 if _, err := os.Stat(path); err != nil {
218 t.Fatalf("artifact removed without proof: %s: %v", path, err)
219 }
220 }
221 })
222
223 t.Run("cleanup error", func(t *testing.T) {
224 s, ctl, host, _, dropin := legacyClaimFixture(t)
225 if err := os.Remove(dropin); err != nil {
226 t.Fatal(err)
227 }
228 if err := os.Mkdir(dropin, 0700); err != nil {
229 t.Fatal(err)
230 }
231 if err := os.WriteFile(filepath.Join(dropin, "busy"), nil, 0600); err != nil {
232 t.Fatal(err)
233 }
234 rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier())
235 if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") {
236 t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined())
237 }
238 })
239
240 t.Run("reload error", func(t *testing.T) {
241 s, ctl, host, snapshot, dropin := legacyClaimFixture(t)
242 s.Systemctl = func(ctx context.Context, args ...string) (string, error) {
243 if args[0] == "daemon-reload" {
244 return "", errors.New("reload failed")
245 }
246 return ctl.run(ctx, args...)
247 }
248 rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier())
249 if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") {
250 t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined())
251 }
252 for _, path := range []string{snapshot, dropin} {
253 if _, err := os.Stat(path); err != nil {
254 t.Fatalf("artifact removed after reload failure: %s: %v", path, err)
255 }
256 }
257 })
258}
259
260func TestRev3LegacyStartWithoutClaimArtifactsKeepsOldPath(t *testing.T) {
261 ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID": "new\n"}}
262 s := &Server{Systemctl: ctl.run}
263 rec := legacyRequest(t, s, "/start?host=shop.example&role=run&memory_max="+legacyTier())
264 if rec.Code != http.StatusOK {
265 t.Fatalf("start=%d: %s", rec.Code, rec.Body.String())
266 }
267 if calls := ctl.joined(); strings.Contains(calls, "ActiveState") || strings.Contains(calls, "daemon-reload") {
268 t.Fatalf("artifact-free start gained cleanup calls:\n%s", calls)
269 }
270}
271