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 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14
15 "github.com/carlosframework/platform/internal/cgroup"
16 "github.com/carlosframework/platform/internal/provision"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// With generations on, the agent names the serving generation's staged
21// build for a run's command, as it does for the legacy sidecar
22// (zero-downtime spec §7.5). Root validates it before the host's executable
23// mount is pointed at it, and a refusal answers 403 having started nothing.
24// A command that names none binds the live link, as before. Only a cold
25// slice gets the tier: a populated one belongs to the instance's owner, and
26// during an overlap it is the raised parent of two generations (§7.3).
27func TestCmdStartMountsTheServingStagedExe(t *testing.T) {
28 opt, exeRoot, cgroupRoot := t.TempDir(), t.TempDir(), t.TempDir()
29 staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
30 digest := writeStaged(t, staged, "build v2")
31 good := StagedExe{Path: staged, Version: "v2", Digest: digest}
32 ctl := &fakeCtl{answers: map[string]string{
33 "show -p ActiveState --value": "inactive\n",
34 "show -p InvocationID --value": "old\n",
35 }}
36 arm := func(inv string) {
37 ctl.onStart = func() { ctl.set("show -p InvocationID --value", inv+"\n") }
38 }
39 _, sock := serveOnSocket(t, ctl, fastCmd, func(s *Server) {
40 s.ExeRoot, s.OptDir, s.CgroupRoot = exeRoot, opt, cgroupRoot
41 s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
42 })
43 c := NewClient(sock)
44 ctx := context.Background()
45 // The command's own source (merge ruling M4), not the host's app.
46 app := filepath.Join(exeRoot, provision.InstanceID(genHost), CmdExeApp)
47
48 arm("inv-1")
49 if inv, err := c.CmdExe(ctx, genHost, cgroup.TierDefault.MemoryMax, good); err != nil || inv != "inv-1" {
50 t.Fatalf("CmdExe = %q, %v; want inv-1", inv, err)
51 }
52 if got, err := os.Readlink(app); err != nil || got != staged {
53 t.Fatalf("the command's exe = %q (%v), want the staged %q", got, err, staged)
54 }
55 if !ctlCalled(ctl, "set-property --runtime "+SliceName(genHost)) {
56 t.Fatal("a cold slice got no tier")
57 }
58
59 for name, x := range map[string]StagedExe{
60 "bytes that differ": {Path: staged, Version: "v2", Digest: strings.Repeat("0", 64)},
61 "a path outside": {Path: "/etc/passwd", Version: "v2", Digest: digest},
62 } {
63 ctl.reset()
64 if _, err := c.CmdExe(ctx, genHost, cgroup.TierDefault.MemoryMax, x); !errors.Is(err, ErrStagedExe) {
65 t.Errorf("%s: %v, want ErrStagedExe", name, err)
66 }
67 if ctlCalled(ctl, "start") || ctlCalled(ctl, "set-property") {
68 t.Errorf("%s: a refused command reached systemd:\n%s", name, ctl.joined())
69 }
70 if got, _ := os.Readlink(app); got != staged {
71 t.Errorf("%s: a refused command moved the mount to %q", name, got)
72 }
73 }
74
75 events := filepath.Join(cgroupRoot, parentSlices, SliceName(genHost), "cgroup.events")
76 if err := os.MkdirAll(filepath.Dir(events), 0o755); err != nil {
77 t.Fatal(err)
78 }
79 if err := os.WriteFile(events, []byte("populated 1\nfrozen 0\n"), 0o644); err != nil {
80 t.Fatal(err)
81 }
82 ctl.reset()
83 arm("inv-2")
84 if _, err := c.CmdExe(ctx, genHost, cgroup.TierDefault.MemoryMax, good); err != nil {
85 t.Fatal(err)
86 }
87 if ctlCalled(ctl, "set-property") {
88 t.Fatal("a command rewrote the populated slice's limit")
89 }
90
91 ctl.reset()
92 arm("inv-3")
93 if _, err := c.Cmd(ctx, genHost, cgroup.TierDefault.MemoryMax); err != nil {
94 t.Fatal(err)
95 }
96 if got, err := os.Readlink(app); err != nil || got != filepath.Join(LiveDir, genHost) {
97 t.Fatalf("a command naming no build has exe %q (%v), want the live link", got, err)
98 }
99 if ctlCalled(ctl, "set-property") {
100 t.Fatal("a command without a build rewrote the populated slice's limit")
101 }
102}
103
104// bindRecorder is a systemctl that reads, at each start, the source the
105// starting unit binds as its /opt/carlos/app, which is what systemd would
106// bind at that moment: cmd-app for a command, app for a sidecar.
107type bindRecorder struct {
108 mu sync.Mutex
109 dir string // <ExeRoot>/<instance id>
110 inv int
111 cmd []string // what each command start would bind
112 sidecar []string // what each sidecar start would bind
113}
114
115func (r *bindRecorder) run(_ context.Context, args ...string) (string, error) {
116 line := strings.Join(args, " ")
117 switch {
118 case args[0] == "start":
119 unit := args[len(args)-1]
120 src, into := "app", &r.sidecar
121 if strings.HasPrefix(unit, UnitPrefixCmd) {
122 src, into = CmdExeApp, &r.cmd
123 }
124 got, err := os.Readlink(filepath.Join(r.dir, src))
125 if err != nil {
126 got = err.Error()
127 }
128 r.mu.Lock()
129 *into = append(*into, got)
130 r.inv++
131 r.mu.Unlock()
132 case strings.HasPrefix(line, "show -p InvocationID --value"):
133 r.mu.Lock()
134 defer r.mu.Unlock()
135 return fmt.Sprintf("inv-%d\n", r.inv), nil
136 case strings.HasPrefix(line, "show -p ActiveState --value"):
137 return "inactive\n", nil
138 }
139 return "", nil
140}
141
142// A command and a sidecar of one instance start at the same time on
143// different builds, as around a swap: each start binds its own build,
144// because the command's source is its own (merge ruling M4). With one
145// shared source, either start could repoint the other's before systemd
146// binds it. A stopped command's source is removed.
147func TestConcurrentCmdAndSidecarStartsBindTheirOwnBuilds(t *testing.T) {
148 const host = "shop.example.com"
149 opt, exeRoot := t.TempDir(), t.TempDir()
150 stage := func(v string) StagedExe {
151 path := release.VersionPath(opt, release.OpsAccount, v, "blog")
152 return StagedExe{Path: path, Version: v, Digest: writeStaged(t, path, "build "+v)}
153 }
154 cmdBuild, sidecarBuild := stage("v2"), stage("v3")
155 rec := &bindRecorder{dir: filepath.Join(exeRoot, provision.InstanceID(host))}
156 _, sock := serveOnSocket(t, &fakeCtl{answers: map[string]string{}}, fastCmd, func(s *Server) {
157 s.Systemctl = rec.run
158 s.ExeRoot, s.OptDir = exeRoot, opt
159 s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
160 })
161 c := NewClient(sock)
162 ctx := context.Background()
163 const rounds = 40
164 var wg sync.WaitGroup
165 errs := make(chan error, 2*rounds)
166 wg.Add(2)
167 go func() {
168 defer wg.Done()
169 for range rounds {
170 if _, err := c.CmdExe(ctx, host, cgroup.TierDefault.MemoryMax, cmdBuild); err != nil {
171 errs <- fmt.Errorf("CmdExe: %w", err)
172 }
173 }
174 }()
175 go func() {
176 defer wg.Done()
177 for range rounds {
178 if _, err := c.StartSidecarExe(ctx, host, cgroup.TierDefault.MemoryMax, sidecarBuild); err != nil {
179 errs <- fmt.Errorf("StartSidecarExe: %w", err)
180 }
181 }
182 }()
183 wg.Wait()
184 close(errs)
185 for err := range errs {
186 t.Fatal(err)
187 }
188 rec.mu.Lock()
189 defer rec.mu.Unlock()
190 if len(rec.cmd) != rounds || len(rec.sidecar) != rounds {
191 t.Fatalf("starts: %d commands, %d sidecars; want %d each", len(rec.cmd), len(rec.sidecar), rounds)
192 }
193 for i, got := range rec.cmd {
194 if got != cmdBuild.Path {
195 t.Fatalf("command start %d would bind %q, want its own build %q", i, got, cmdBuild.Path)
196 }
197 }
198 for i, got := range rec.sidecar {
199 if got != sidecarBuild.Path {
200 t.Fatalf("sidecar start %d would bind %q, want its own build %q", i, got, sidecarBuild.Path)
201 }
202 }
203 if err := c.Stop(ctx, host, RoleCmd); err != nil {
204 t.Fatal(err)
205 }
206 for _, name := range []string{CmdExeApp, CmdExeLive} {
207 if _, err := os.Lstat(filepath.Join(rec.dir, name)); !os.IsNotExist(err) {
208 t.Errorf("%s survived the command's stop (%v)", name, err)
209 }
210 }
211 if got, err := os.Readlink(filepath.Join(rec.dir, "app")); err != nil || got != sidecarBuild.Path {
212 t.Errorf("the command's stop touched the host's app: %q (%v)", got, err)
213 }
214}
215