carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package instancectl
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/cgroup"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23func writeStaged(t *testing.T, path, body string) string {
24 t.Helper()
25 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
26 t.Fatal(err)
27 }
28 if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
29 t.Fatal(err)
30 }
31 sum := sha256.Sum256([]byte(body))
32 return hex.EncodeToString(sum[:])
33}
34
35// Root mounts only the host's own app's staged build, reached without a
36// symlink, whose bytes are the attempt's (zero-downtime plan 3, Task 9).
37func TestValidateStagedExe(t *testing.T) {
38 opt := t.TempDir()
39 host := "blog.example.com"
40 s := &Server{OptDir: opt, HostApp: func(h string) (string, string, error) {
41 if h != host {
42 return "", "", errors.New("no such route")
43 }
44 return release.OpsAccount, "blog", nil
45 }}
46 path := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
47 digest := writeStaged(t, path, "build v2")
48 if err := s.validateStagedExe(host, StagedExe{Path: path, Version: "v2", Digest: digest}); err != nil {
49 t.Fatalf("the host's own staged build was refused: %v", err)
50 }
51 shop := release.VersionPath(opt, release.OpsAccount, "v2", "shop")
52 shopDigest := writeStaged(t, shop, "build v2")
53 // v3 of blog reached through a symlinked version directory.
54 if err := os.Symlink(filepath.Join(opt, "versions", "v2"), filepath.Join(opt, "versions", "v3")); err != nil {
55 t.Fatal(err)
56 }
57 linked := release.VersionPath(opt, release.OpsAccount, "v3", "blog")
58 for name, tc := range map[string]struct {
59 host string
60 x StagedExe
61 }{
62 "another app's build": {host, StagedExe{Path: shop, Version: "v2", Digest: shopDigest}},
63 "another version": {host, StagedExe{Path: path, Version: "v3", Digest: digest}},
64 "a path outside": {host, StagedExe{Path: "/etc/passwd", Version: "v2", Digest: digest}},
65 "bytes that differ": {host, StagedExe{Path: path, Version: "v2", Digest: strings.Repeat("0", 64)}},
66 "a symlinked component": {host, StagedExe{Path: linked, Version: "v3", Digest: digest}},
67 "a bad version": {host, StagedExe{Path: path, Version: "../v2", Digest: digest}},
68 "an unknown host": {"other.example.com", StagedExe{Path: path, Version: "v2", Digest: digest}},
69 } {
70 if err := s.validateStagedExe(tc.host, tc.x); !errors.Is(err, ErrStagedExe) {
71 t.Errorf("%s: %v, want ErrStagedExe", name, err)
72 }
73 }
74 if err := (&Server{OptDir: opt}).validateStagedExe(host, StagedExe{Path: path, Version: "v2", Digest: digest}); !errors.Is(err, ErrStagedExe) {
75 t.Errorf("no registry view: %v, want ErrStagedExe", err)
76 }
77}
78
79// ctlCalled reports whether any recorded systemctl call starts with prefix.
80func ctlCalled(c *fakeCtl, prefix string) bool {
81 c.mu.Lock()
82 defer c.mu.Unlock()
83 for _, l := range c.calls {
84 if strings.HasPrefix(l, prefix) {
85 return true
86 }
87 }
88 return false
89}
90
91func (f *fakeCtl) reset() {
92 f.mu.Lock()
93 f.calls = nil
94 f.mu.Unlock()
95}
96
97// The nested legacy sidecar runs the serving generation's staged build
98// (spec §7.5), validated by root under the run unit's rule before its
99// executable mount is pointed at it. The live link names another build
100// throughout and makes no difference. A refused path answers 403 and
101// leaves the mount alone. Before this task a sidecar /start could not name
102// an executable at all, so the sidecar bound the live link.
103func TestSidecarStartMountsTheValidatedStagedExe(t *testing.T) {
104 opt, exeRoot, cgroupRoot := t.TempDir(), t.TempDir(), t.TempDir()
105 staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
106 digest := writeStaged(t, staged, "build v2")
107 ctl := &fakeCtl{}
108 _, sock := serveOnSocket(t, ctl, func(s *Server) {
109 s.DropInDir, s.ExeRoot, s.OptDir, s.CgroupRoot = t.TempDir(), exeRoot, opt, cgroupRoot
110 s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
111 })
112 c := NewClient(sock)
113 app := filepath.Join(exeRoot, provision.InstanceID(genHost), "app")
114 if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax,
115 StagedExe{Path: staged, Version: "v2", Digest: digest}); err != nil {
116 t.Fatalf("the host's own staged build was refused for its sidecar: %v", err)
117 }
118 if got, err := os.Readlink(app); err != nil || got != staged {
119 t.Fatalf("the sidecar's exe = %q (%v), want the staged %q", got, err, staged)
120 }
121 for name, x := range map[string]StagedExe{
122 "bytes that differ": {Path: staged, Version: "v2", Digest: strings.Repeat("0", 64)},
123 "a path outside": {Path: "/etc/passwd", Version: "v2", Digest: digest},
124 } {
125 if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, x); !errors.Is(err, ErrStagedExe) {
126 t.Errorf("%s: %v, want ErrStagedExe", name, err)
127 }
128 if got, _ := os.Readlink(app); got != staged {
129 t.Errorf("%s: a refused start moved the mount to %q", name, got)
130 }
131 }
132 // The slice above was cold, so its tier was set. With a generation
133 // running (populated), a staged sidecar start leaves the slice's limit
134 // alone: during an overlap it is the raised parent of both generations
135 // (spec §7.3), and a start that set one tier would put them both under
136 // it. A flag-off start (no exe) sets the tier as today.
137 if !ctlCalled(ctl, "set-property --runtime "+SliceName(genHost)) {
138 t.Fatal("a cold slice got no tier")
139 }
140 events := filepath.Join(cgroupRoot, parentSlices, SliceName(genHost), "cgroup.events")
141 if err := os.MkdirAll(filepath.Dir(events), 0o755); err != nil {
142 t.Fatal(err)
143 }
144 if err := os.WriteFile(events, []byte("populated 1\nfrozen 0\n"), 0o644); err != nil {
145 t.Fatal(err)
146 }
147 ctl.reset()
148 if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax,
149 StagedExe{Path: staged, Version: "v2", Digest: digest}); err != nil {
150 t.Fatal(err)
151 }
152 if ctlCalled(ctl, "set-property") {
153 t.Fatal("a staged sidecar start rewrote the populated generation parent's limit")
154 }
155 if _, err := c.Start(context.Background(), genHost, RoleSidecar, cgroup.TierDefault.MemoryMax); err != nil {
156 t.Fatal(err)
157 }
158 if !ctlCalled(ctl, "set-property --runtime "+SliceName(genHost)) {
159 t.Fatal("a flag-off sidecar start no longer sets the tier")
160 }
161 // A run-role start on the original template may name an executable now
162 // (settings spec §4.2: the cold wake runs the build it recorded), and
163 // root still refuses one that is not the host's own validated build.
164 q := hostRole(genHost, RoleRun)
165 q.Set("memory_max", strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10))
166 q.Set("exe", staged)
167 q.Set("version", "v2")
168 q.Set("digest", strings.Repeat("0", 64))
169 if code, _, _ := c.do(context.Background(), DeadlineControl, http.MethodPost, "/start", q, nil); code != http.StatusForbidden {
170 t.Errorf("a run-role start with a wrong digest answered %d, want 403", code)
171 }
172}
173
174// originalTemplateServer serves root's control channel over a version store
175// holding the host's own staged v2 and v3, with the live link on v2. It
176// returns the client, the systemctl fake, v2 as the exe root must accept,
177// the host's app mount and the opt dir.
178func originalTemplateServer(t *testing.T) (*Client, *fakeCtl, StagedExe, string, string) {
179 t.Helper()
180 opt, exeRoot := t.TempDir(), t.TempDir()
181 staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
182 digest := writeStaged(t, staged, "build v2")
183 writeStaged(t, release.VersionPath(opt, release.OpsAccount, "v3", "blog"), "build v3")
184 repointLiveLink(t, opt, genHost, staged)
185 ctl := &fakeCtl{answers: map[string]string{
186 "show -p InvocationID,Result,ExecMainStatus,ExecMainCode": "InvocationID=feed\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n",
187 }}
188 _, sock := serveOnSocket(t, ctl, func(s *Server) {
189 s.DropInDir, s.ExeRoot, s.OptDir = t.TempDir(), exeRoot, opt
190 s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
191 })
192 app := filepath.Join(exeRoot, provision.InstanceID(genHost), "app")
193 return NewClient(sock), ctl, StagedExe{Path: staged, Version: "v2", Digest: digest}, app, opt
194}
195
196// repointLiveLink is the agent's repoint of <opt>/live/<host>.
197func repointLiveLink(t *testing.T, opt, host, target string) {
198 t.Helper()
199 link := release.LiveLink(opt, host)
200 if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
201 t.Fatal(err)
202 }
203 os.Remove(link)
204 if err := os.Symlink(target, link); err != nil {
205 t.Fatal(err)
206 }
207}
208
209// Settings spec §4.2: the original run unit (generation 0) binds the
210// recorded staged file, validated under zdd-3's rule, and a repoint of the
211// live link after the start does not move it. A wrong digest is 403
212// (ErrStagedExe) before systemd or the mount is touched. A start that
213// names no executable binds the live link, as today.
214func TestTheRunUnitBindsAValidatedStagedExe(t *testing.T) {
215 c, ctl, x, app, opt := originalTemplateServer(t)
216 if _, err := c.StartExe(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax, x); err != nil {
217 t.Fatalf("the host's own staged build was refused for its run unit: %v", err)
218 }
219 if !ctlCalled(ctl, "start "+UnitName(RoleRun, genHost)) {
220 t.Fatalf("the run unit was not started:\n%s", ctl.joined())
221 }
222 repointLiveLink(t, opt, genHost, release.VersionPath(opt, release.OpsAccount, "v3", "blog"))
223 if got, err := os.Readlink(app); err != nil || got != x.Path {
224 t.Fatalf("the run unit's app mount = %q (%v), want the recorded %q", got, err, x.Path)
225 }
226
227 ctl.reset()
228 bad := x
229 bad.Digest = strings.Repeat("0", 64)
230 if _, err := c.StartExe(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax, bad); !errors.Is(err, ErrStagedExe) {
231 t.Fatalf("a wrong digest = %v, want ErrStagedExe", err)
232 }
233 if ctl.joined() != "" {
234 t.Fatalf("a refused start reached systemctl:\n%s", ctl.joined())
235 }
236 if got, _ := os.Readlink(app); got != x.Path {
237 t.Fatalf("a refused start moved the mount to %q", got)
238 }
239
240 if _, err := c.Start(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax); err != nil {
241 t.Fatal(err)
242 }
243 if got, _ := os.Readlink(app); got != filepath.Join(LiveDir, genHost) {
244 t.Fatalf("a run start with no executable bound %q, want the live link", got)
245 }
246}
247
248// The seed unit binds the recorded staged file the same way, through
249// /seed, which names no role. A wrong digest is 403 before anything runs,
250// and a seed that names no executable binds the live link, as today.
251func TestTheSeedUnitBindsAValidatedStagedExe(t *testing.T) {
252 c, ctl, x, app, opt := originalTemplateServer(t)
253 res, err := c.SeedExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, x)
254 if err != nil || res.Result != "success" {
255 t.Fatalf("the host's own staged build was refused for its seed: %+v, %v", res, err)
256 }
257 if !ctlCalled(ctl, "start "+UnitName(RoleSeed, genHost)) {
258 t.Fatalf("the seed unit was not started:\n%s", ctl.joined())
259 }
260 repointLiveLink(t, opt, genHost, release.VersionPath(opt, release.OpsAccount, "v3", "blog"))
261 if got, err := os.Readlink(app); err != nil || got != x.Path {
262 t.Fatalf("the seed unit's app mount = %q (%v), want the recorded %q", got, err, x.Path)
263 }
264
265 ctl.reset()
266 bad := x
267 bad.Digest = strings.Repeat("0", 64)
268 if _, err := c.SeedExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, bad); !errors.Is(err, ErrStagedExe) {
269 t.Fatalf("a wrong digest = %v, want ErrStagedExe", err)
270 }
271 if ctl.joined() != "" {
272 t.Fatalf("a refused seed reached systemctl:\n%s", ctl.joined())
273 }
274 if got, _ := os.Readlink(app); got != x.Path {
275 t.Fatalf("a refused seed moved the mount to %q", got)
276 }
277
278 if _, err := c.Seed(context.Background(), genHost, cgroup.TierDefault.MemoryMax); err != nil {
279 t.Fatal(err)
280 }
281 if got, _ := os.Readlink(app); got != filepath.Join(LiveDir, genHost) {
282 t.Fatalf("a seed with no executable bound %q, want the live link", got)
283 }
284}
285
286// stagedGenServer is genServer with a version store holding the host's own
287// staged v2 build. It returns the staged exe root has to accept.
288func stagedGenServer(t *testing.T, ctl *fakeCtl) (*Server, string, StagedExe) {
289 t.Helper()
290 opt := t.TempDir()
291 staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
292 digest := writeStaged(t, staged, "build v2")
293 dropins, exe := t.TempDir(), t.TempDir()
294 s, sock := serveOnSocket(t, ctl, func(s *Server) {
295 s.DropInDir, s.ExeRoot, s.OptDir = dropins, exe, opt
296 s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
297 })
298 return s, sock, StagedExe{Path: staged, Version: "v2", Digest: digest}
299}
300
301// A validated staged generation start mounts the staged build. A refused one
302// answers ErrStagedExe having touched neither systemd nor the disk.
303func TestGenStartExeMountsTheValidatedBuildAndRefusalTouchesNothing(t *testing.T) {
304 ctl := genCtl()
305 s, sock, x := stagedGenServer(t, ctl)
306 c := NewClient(sock)
307 bad := x
308 bad.Digest = strings.Repeat("0", 64)
309 if _, err := c.StartGenExe(context.Background(), genHost, 7, cgroup.TierDefault.MemoryMax, bad); !errors.Is(err, ErrStagedExe) {
310 t.Fatalf("StartGenExe(bad digest) = %v, want ErrStagedExe", err)
311 }
312 if ctl.joined() != "" {
313 t.Fatalf("a refused staged start reached systemctl:\n%s", ctl.joined())
314 }
315 if _, err := os.Stat(filepath.Join(s.ExeRoot, GenKey(genHost, 7))); !os.IsNotExist(err) {
316 t.Fatal("an executable mount was laid out for a refused start")
317 }
318 if _, err := c.StartGenExe(context.Background(), genHost, 7, cgroup.TierDefault.MemoryMax, x); err != nil {
319 t.Fatalf("StartGenExe: %v", err)
320 }
321 if got, err := os.Readlink(filepath.Join(s.ExeRoot, GenKey(genHost, 7), "app")); err != nil || got != x.Path {
322 t.Fatalf("generation 7's exe = %q (%v), want %q", got, err, x.Path)
323 }
324}
325
326// The staged start keeps plan 2's answers: a cleanup that cannot prove the
327// unit gone is ErrGenUnproven and not ErrStagedExe, and its files stay.
328func TestGenStartExeKeepsTheUnprovenAnswer(t *testing.T) {
329 ctl := genCtl()
330 ctl.fail = map[string]error{"show -p InvocationID": errors.New("bus timeout")}
331 s, sock, x := stagedGenServer(t, ctl)
332 s.cgroupWaitBudget = 100 * time.Millisecond
333 populateGenCgroup(t, s)
334 _, err := NewClient(sock).StartGenExe(context.Background(), genHost, 7, cgroup.TierDefault.MemoryMax, x)
335 if !errors.Is(err, ErrGenUnproven) || errors.Is(err, ErrStagedExe) {
336 t.Fatalf("StartGenExe = %v, want ErrGenUnproven only", err)
337 }
338 if _, err := os.Stat(filepath.Join(s.ExeRoot, GenKey(genHost, 7))); err != nil {
339 t.Fatalf("the exe layout of an unproven generation was removed: %v", err)
340 }
341}
342
343// exe, version and digest belong to the start and seed verbs only. On
344// those, an executable root cannot vouch for is refused (403) before
345// systemd is asked anything.
346func TestStagedExeParametersRefusedWhereTheyDoNotBelong(t *testing.T) {
347 ctl := genCtl()
348 _, c := newServer(t, ctl, func(s *Server) { s.DropInDir = t.TempDir() })
349 for _, path := range []string{
350 "/stop?host=" + genHost + "&role=run&gen=7&exe=/x",
351 "/stop?host=" + genHost + "&role=sidecar&exe=/x",
352 "/reset?host=" + genHost + "&role=sidecar&version=v2",
353 "/seed?host=" + genHost + "&memory_max=536870912&version=v2",
354 "/start?host=" + genHost + "&role=run&memory_max=536870912&gen=7&version=v2",
355 } {
356 if resp := post(t, c, path); resp.StatusCode != 400 {
357 t.Errorf("%s: status %d, want 400", path, resp.StatusCode)
358 }
359 }
360 // Settings spec §4.2: the original run unit and the seed unit may name
361 // an executable; this one is not the host's own validated build.
362 for _, path := range []string{
363 "/seed?host=" + genHost + "&memory_max=536870912&exe=/x",
364 "/start?host=" + genHost + "&role=run&memory_max=536870912&exe=/x&version=v2&digest=d",
365 } {
366 if resp := post(t, c, path); resp.StatusCode != http.StatusForbidden {
367 t.Errorf("%s: status %d, want 403", path, resp.StatusCode)
368 }
369 }
370 if ctl.joined() != "" {
371 t.Errorf("systemctl was called:\n%s", ctl.joined())
372 }
373}
374