| 1 | //go:build linux |
| 2 | |
| 3 | package instanceexec |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/instancectl" |
| 13 | ) |
| 14 | |
| 15 | func envOf(m map[string]string) func(string) string { |
| 16 | return func(k string) string { return m[k] } |
| 17 | } |
| 18 | |
| 19 | func TestWriteExitRecordWritesTheFourVariables(t *testing.T) { |
| 20 | run := t.TempDir() |
| 21 | iid := "h.example.com" |
| 22 | if err := os.MkdirAll(filepath.Join(run, "instances", iid), 0o700); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC) |
| 26 | env := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "exit-code", "EXIT_CODE": "exited", "EXIT_STATUS": "3"}) |
| 27 | if err := writeExitRecord(run, instancectl.RoleRun, iid, env, now); err != nil { |
| 28 | t.Fatalf("writeExitRecord: %v", err) |
| 29 | } |
| 30 | b, err := os.ReadFile(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | var rec instancectl.ExitRecord |
| 35 | if err := json.Unmarshal(b, &rec); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if rec.Invocation != "deadbeefdeadbeefdeadbeefdeadbeef" || rec.Result != "exit-code" || rec.Code != "exited" || rec.Status != "3" || !rec.At.Equal(now) { |
| 39 | t.Errorf("record = %+v", rec) |
| 40 | } |
| 41 | // 0600: the record is diagnostic detail about this instance's own exit, |
| 42 | // not something every reader of the instance directory needs (final |
| 43 | // review, plan 2, item 6). |
| 44 | if fi, err := os.Stat(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")); err != nil || fi.Mode().Perm() != 0o600 { |
| 45 | t.Errorf("mode = %v, %v, want 0600", fi.Mode(), err) |
| 46 | } |
| 47 | // O_EXCL: a second write for the same invocation is refused and the |
| 48 | // first record stands. |
| 49 | env2 := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "success", "EXIT_CODE": "exited", "EXIT_STATUS": "0"}) |
| 50 | if err := writeExitRecord(run, instancectl.RoleRun, iid, env2, now); err == nil { |
| 51 | t.Error("second write for the same invocation succeeded") |
| 52 | } |
| 53 | b2, _ := os.ReadFile(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")) |
| 54 | if string(b2) != string(b) { |
| 55 | t.Error("first record was overwritten") |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestWriteExitRecordWithoutInvocationIsANoop(t *testing.T) { |
| 60 | run := t.TempDir() |
| 61 | iid := "h.example.com" |
| 62 | if err := os.MkdirAll(filepath.Join(run, "instances", iid), 0o700); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if err := writeExitRecord(run, instancectl.RoleSeed, iid, envOf(nil), time.Now()); err != nil { |
| 66 | t.Fatalf("no INVOCATION_ID: %v (must not fail the unit's stop)", err) |
| 67 | } |
| 68 | entries, _ := os.ReadDir(filepath.Join(run, "instances", iid)) |
| 69 | if len(entries) != 0 { |
| 70 | t.Errorf("wrote %v without an invocation id", entries) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestWriteExitRecordRefusesAnInvocationThatIsNotAName(t *testing.T) { |
| 75 | run := t.TempDir() |
| 76 | iid := "h.example.com" |
| 77 | if err := os.MkdirAll(filepath.Join(run, "instances", iid), 0o700); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | env := envOf(map[string]string{"INVOCATION_ID": "../../etc/x", "SERVICE_RESULT": "success"}) |
| 81 | if err := writeExitRecord(run, instancectl.RoleRun, iid, env, time.Now()); err == nil { |
| 82 | t.Error("an invocation id with a path separator was accepted") |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestWriteExitRecordKeysAGenerationRecordByGeneration(t *testing.T) { |
| 87 | run := t.TempDir() |
| 88 | iid := "h.example.com" |
| 89 | if err := os.MkdirAll(filepath.Join(run, "instances", iid), 0o700); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 93 | env := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "success", "EXIT_CODE": "exited", "EXIT_STATUS": "0"}) |
| 94 | if err := writeExitRecord(run, instancectl.RoleRun, instancectl.GenInstance(iid, 7), env, now); err != nil { |
| 95 | t.Fatalf("writeExitRecord: %v", err) |
| 96 | } |
| 97 | if _, err := os.Stat(filepath.Join(run, "instances", iid, "exit.gen.7.deadbeefdeadbeefdeadbeefdeadbeef.json")); err != nil { |
| 98 | t.Errorf("generation record missing: %v", err) |
| 99 | } |
| 100 | if _, err := os.Stat(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")); !os.IsNotExist(err) { |
| 101 | t.Errorf("a generation wrote a legacy record: %v", err) |
| 102 | } |
| 103 | if err := writeExitRecord(run, instancectl.RoleSidecar, iid+":7", env, now); err == nil { |
| 104 | t.Error("a sidecar generation was accepted") |
| 105 | } |
| 106 | } |
| 107 | |