| 1 | //go:build linux |
| 2 | |
| 3 | package activator |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "slices" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/cgroup" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | // Amadan #272, owed before O8: a cold wake's build-state record names the |
| 19 | // build about to run against the instance's database, and completes an |
| 20 | // install request (settings spec §4.2). It used to be written at step 2b, |
| 21 | // before the backend's own refusals: the replica-key gate and the cgroup's |
| 22 | // (a populated legacy parent, a killed cgroup kept, a limit not applied). |
| 23 | // A launch refused there ran nothing, yet left a record naming its build. |
| 24 | // The record is now written at the exec, so a refused launch leaves none. |
| 25 | |
| 26 | // execIdentityBackend is fakeBackend for the lease and the restore, with a |
| 27 | // real ExecBackend for the identity, the seed and the spawn: the exec seams |
| 28 | // and the refusals under test are the production ones. |
| 29 | type execIdentityBackend struct { |
| 30 | *fakeBackend |
| 31 | eb *ExecBackend |
| 32 | } |
| 33 | |
| 34 | func (b *execIdentityBackend) identityFor(registry.Route) identityBackend { return b.eb } |
| 35 | |
| 36 | // stageScript makes body host's live build v1, as the adopt pass lays it |
| 37 | // out (<opt>/versions/v1/app, linked from <opt>/live/<host>). |
| 38 | func stageScript(t *testing.T, eb *ExecBackend, host, body string) { |
| 39 | t.Helper() |
| 40 | stageLive(t, eb, host, "v1") |
| 41 | if err := os.WriteFile(filepath.Join(eb.OptDir, "versions", "v1", "app"), []byte(body), 0o755); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // refusedWake runs one cold wake of rt over eb with a recording BeforeExec, |
| 47 | // and returns the wake's error and the records written. |
| 48 | func refusedWake(t *testing.T, eb *ExecBackend, rt registry.Route) (error, []string) { |
| 49 | t.Helper() |
| 50 | fb := &fakeBackend{restoreFresh: true} |
| 51 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | t.Cleanup(func() { reg.Close() }) |
| 56 | a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &execIdentityBackend{fakeBackend: fb, eb: eb}, reg) |
| 57 | l := &execLog{} |
| 58 | a.BeforeExec = l.hook |
| 59 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| 60 | defer cancel() |
| 61 | done, err := a.beginRequest(ctx, rt) |
| 62 | if err == nil { |
| 63 | done() |
| 64 | } |
| 65 | calls, _, _ := l.seen() |
| 66 | return err, calls |
| 67 | } |
| 68 | |
| 69 | func TestARefusedLaunchLeavesNoBuildStateRecord(t *testing.T) { |
| 70 | t.Parallel() |
| 71 | const host = "shop.example.com" |
| 72 | route := func(eb *ExecBackend) registry.Route { |
| 73 | return registry.Route{Host: host, Kind: "instance", Hibernate: true, App: "app", |
| 74 | Socket: noControlSocket(t), DBPath: filepath.Join(eb.DataDir, host+".db")} |
| 75 | } |
| 76 | |
| 77 | t.Run("replica seed refused by the replica-key gate", func(t *testing.T) { |
| 78 | eb := testExecBackend(t) |
| 79 | stageScript(t, eb, host, "#!/bin/sh\nexit 0\n") |
| 80 | rt := route(eb) |
| 81 | rt.Replica, rt.Seed = true, `["seed"]` |
| 82 | err, calls := refusedWake(t, eb, rt) |
| 83 | if !errors.Is(err, ErrReplicaKey) { |
| 84 | t.Fatalf("wake = %v, want the replica-key gate's refusal", err) |
| 85 | } |
| 86 | if len(calls) != 0 { |
| 87 | t.Fatalf("records %v after a refused seed, want none", calls) |
| 88 | } |
| 89 | }) |
| 90 | |
| 91 | t.Run("replica spawn refused by the replica-key gate", func(t *testing.T) { |
| 92 | eb := testExecBackend(t) |
| 93 | stageScript(t, eb, host, "#!/bin/sh\nexit 0\n") |
| 94 | rt := route(eb) |
| 95 | rt.Replica = true |
| 96 | err, calls := refusedWake(t, eb, rt) |
| 97 | if !errors.Is(err, ErrReplicaKey) { |
| 98 | t.Fatalf("wake = %v, want the replica-key gate's refusal", err) |
| 99 | } |
| 100 | if len(calls) != 0 { |
| 101 | t.Fatalf("records %v after a refused spawn, want none", calls) |
| 102 | } |
| 103 | }) |
| 104 | |
| 105 | t.Run("seed refused beside a populated legacy parent", func(t *testing.T) { |
| 106 | eb, parent := delegatedBackend(t, host) |
| 107 | eb.GenContainment = true |
| 108 | if err := os.WriteFile(filepath.Join(parent, "cgroup.procs"), []byte("4242\n"), 0o644); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | stageScript(t, eb, host, "#!/bin/sh\nexit 0\n") |
| 112 | rt := route(eb) |
| 113 | rt.Seed = `["seed"]` |
| 114 | err, calls := refusedWake(t, eb, rt) |
| 115 | if !errors.Is(err, cgroup.ErrLegacyParentPopulated) { |
| 116 | t.Fatalf("wake = %v, want the cgroup's refusal", err) |
| 117 | } |
| 118 | if len(calls) != 0 { |
| 119 | t.Fatalf("records %v after a refused seed, want none", calls) |
| 120 | } |
| 121 | }) |
| 122 | |
| 123 | // The control: a seed that runs (and fails) did execute its build |
| 124 | // against the database, so it is recorded, once, before it ran. |
| 125 | t.Run("a seed that ran is recorded", func(t *testing.T) { |
| 126 | eb := testExecBackend(t) |
| 127 | stageScript(t, eb, host, "#!/bin/sh\nexit 1\n") |
| 128 | rt := route(eb) |
| 129 | rt.Seed = `["seed"]` |
| 130 | err, calls := refusedWake(t, eb, rt) |
| 131 | if err == nil { |
| 132 | t.Fatal("a failing seed woke the instance") |
| 133 | } |
| 134 | if !slices.Equal(calls, []string{host + "@v1"}) { |
| 135 | t.Fatalf("records %v, want v1 once", calls) |
| 136 | } |
| 137 | }) |
| 138 | } |
| 139 | |