| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "github.com/carlosframework/platform/internal/hostwrite" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "reflect" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/activator" |
| 16 | "github.com/carlosframework/platform/internal/cgroup" |
| 17 | "github.com/carlosframework/platform/internal/hostbox" |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | "github.com/carlosframework/platform/internal/provision" |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | ) |
| 22 | |
| 23 | const shapeData = "/data" |
| 24 | |
| 25 | func shapeAssembly() *ClaimRoleAssembly { |
| 26 | return &ClaimRoleAssembly{Root: fakeLaunchRoot{protocol: hostbox.RoleLaunchProtocol}, ManagedRoots: map[string]string{"data": shapeData}} |
| 27 | } |
| 28 | |
| 29 | func shapeInputs(host string) ClaimLaunchInputs { |
| 30 | db := filepath.Join(provision.InstanceDir(shapeData, host), host+".db") |
| 31 | return ClaimLaunchInputs{Executable: "/opt/carlos/versions/v1/app", ExecutableSHA256: strings.Repeat("a", 64), Identity: hostwrite.Identity{Config: "config", Release: "release"}, Roles: []string{"main"}, DBPath: db, Limit: cgroup.TierDefault, |
| 32 | Args: []string{"--socket", provision.NestedSocket(host), "--db", db}, |
| 33 | Env: []string{"CARLOS_ADMIN_TOKEN=secret", "PATH=/usr/bin"}} |
| 34 | } |
| 35 | |
| 36 | func TestRev2ShapePreflightLaunchAdmitsValidMain(t *testing.T) { |
| 37 | if err := shapeAssembly().PreflightLaunch("shop.example", shapeInputs("shop.example")); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func TestRev2ShapePreflightLaunchRefusesMalformedLaunch(t *testing.T) { |
| 43 | host := "shop.example" |
| 44 | good := shapeInputs(host) |
| 45 | db, sock := good.DBPath, provision.NestedSocket(host) |
| 46 | other := filepath.Join(provision.InstanceDir(shapeData, host), "other.db") |
| 47 | for name, mutate := range map[string]func(*ClaimLaunchInputs){ |
| 48 | "seed role": func(in *ClaimLaunchInputs) { in.Roles = []string{"main", "seed"} }, |
| 49 | "sidecar role": func(in *ClaimLaunchInputs) { in.Roles = []string{"main", "sidecar"} }, |
| 50 | "no roles": func(in *ClaimLaunchInputs) { in.Roles = nil }, |
| 51 | "no args": func(in *ClaimLaunchInputs) { in.Args = nil }, |
| 52 | "extra flag": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "--verbose", "1") }, |
| 53 | "extra positional": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "serve") }, |
| 54 | "duplicate socket": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "--socket", sock) }, |
| 55 | "duplicate db": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "--db", db) }, |
| 56 | "missing socket": func(in *ClaimLaunchInputs) { in.Args = []string{"--db", db} }, |
| 57 | "missing db": func(in *ClaimLaunchInputs) { in.Args = []string{"--socket", sock} }, |
| 58 | "NUL in arg": func(in *ClaimLaunchInputs) { in.Args = []string{"--socket", sock + "\x00x", "--db", db} }, |
| 59 | "db mismatch": func(in *ClaimLaunchInputs) { in.Args = []string{"--socket", sock, "--db", other} }, |
| 60 | "db flat": func(in *ClaimLaunchInputs) { in.Args = []string{"--socket", sock, "--db", "/data/shop.example.db"} }, |
| 61 | "socket flat": func(in *ClaimLaunchInputs) { |
| 62 | in.Args = []string{"--socket", provision.SocketDir + "/shop.example.sock", "--db", db} |
| 63 | }, |
| 64 | "socket other unit": func(in *ClaimLaunchInputs) { |
| 65 | in.Args = []string{"--socket", provision.NestedSocket("other.example"), "--db", db} |
| 66 | }, |
| 67 | "socket not app": func(in *ClaimLaunchInputs) { |
| 68 | in.Args = []string{"--socket", filepath.Join(filepath.Dir(sock), "x.sock"), "--db", db} |
| 69 | }, |
| 70 | "no env": func(in *ClaimLaunchInputs) { in.Env = nil }, |
| 71 | "no admin token": func(in *ClaimLaunchInputs) { in.Env = []string{"PATH=/usr/bin"} }, |
| 72 | "empty admin token": func(in *ClaimLaunchInputs) { in.Env = []string{"CARLOS_ADMIN_TOKEN=", "PATH=/usr/bin"} }, |
| 73 | "malformed env": func(in *ClaimLaunchInputs) { in.Env = append(in.Env, "NOEQUALS") }, |
| 74 | "empty env key": func(in *ClaimLaunchInputs) { in.Env = append(in.Env, "=v") }, |
| 75 | "duplicate env": func(in *ClaimLaunchInputs) { in.Env = append(in.Env, "PATH=/bin") }, |
| 76 | "NUL in env": func(in *ClaimLaunchInputs) { in.Env = append(in.Env, "A=b\x00c") }, |
| 77 | "bad tier": func(in *ClaimLaunchInputs) { in.Limit = cgroup.Limit{MemoryMax: 7} }, |
| 78 | } { |
| 79 | t.Run(name, func(t *testing.T) { |
| 80 | in := good |
| 81 | in.Args, in.Env = append([]string(nil), good.Args...), append([]string(nil), good.Env...) |
| 82 | mutate(&in) |
| 83 | err := shapeAssembly().PreflightLaunch(host, in) |
| 84 | if !errors.Is(err, hostbox.ErrRoleLaunchShape) && !errors.Is(err, hostbox.ErrUnsupported) { |
| 85 | t.Fatalf("accepted: %v", err) |
| 86 | } |
| 87 | }) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Select reports the roles the real start would spawn, not a hardcoded main. |
| 92 | func TestRev2ShapeServingSelectReportsLaunchRoles(t *testing.T) { |
| 93 | for name, tc := range map[string]struct { |
| 94 | roles []string |
| 95 | want []string |
| 96 | }{ |
| 97 | "main": {[]string{"main"}, []string{"main"}}, |
| 98 | "seed": {[]string{"main", "seed"}, []string{"main", "seed"}}, |
| 99 | "sidecar": {[]string{"main", "sidecar"}, []string{"main", "sidecar"}}, |
| 100 | } { |
| 101 | t.Run(name, func(t *testing.T) { |
| 102 | f := plan9D5Fixture(t) |
| 103 | f.launch.Roles = tc.roles |
| 104 | in, err := f.owners.Select(t.Context(), plan9D5Route, plan9D5Observed(hostclaim.Holder{Stamp: 3}), activator.StartPolicy{MaxStarts: 5}) |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if !reflect.DeepEqual(in.Roles, tc.want) { |
| 109 | t.Fatalf("roles=%v want %v", in.Roles, tc.want) |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // The production launch source refuses a route that would spawn a seed or a |
| 116 | // co-spawned sidecar before the admin token is minted. |
| 117 | func TestRev2ShapeLaunchSourceRefusesSeedAndSidecarBeforeTokenMint(t *testing.T) { |
| 118 | t.Parallel() |
| 119 | for name, setup := range map[string]func(*adminTokenFixture){ |
| 120 | "seed": func(f *adminTokenFixture) { f.rt.Seed = `["init"]` }, |
| 121 | "sidecar": func(f *adminTokenFixture) { |
| 122 | if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+"-sidecar.env"), []byte("A=1\n"), 0o600); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | }, |
| 126 | } { |
| 127 | t.Run(name, func(t *testing.T) { |
| 128 | f := newAdminTokenFixture(t, "box-1") |
| 129 | setup(f) |
| 130 | _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt) |
| 131 | if !errors.Is(err, hostbox.ErrUnsupported) { |
| 132 | t.Fatalf("err=%v", err) |
| 133 | } |
| 134 | if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) { |
| 135 | t.Fatalf("token minted for refused shape: %v", err) |
| 136 | } |
| 137 | }) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestRev2ShapeLaunchSourceReportsMainOnly(t *testing.T) { |
| 142 | f := newAdminTokenFixture(t, "box-1") |
| 143 | l, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt) |
| 144 | if err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if !reflect.DeepEqual(l.Roles, []string{"main"}) { |
| 148 | t.Fatalf("roles=%v", l.Roles) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | type shapeMutatedInputs struct { |
| 153 | recoveryInputs |
| 154 | mutate func(*ClaimLaunchInputs) |
| 155 | } |
| 156 | |
| 157 | func (s shapeMutatedInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) { |
| 158 | in, err := s.recoveryInputs.Select(ctx, rt, o, p) |
| 159 | s.mutate(&in) |
| 160 | return in, err |
| 161 | } |
| 162 | |
| 163 | // A malformed launch is refused in Prerequisites before any retry reservation, |
| 164 | // guard record, stop request, lease or journal entry. |
| 165 | func TestRev2ShapeMalformedLaunchRefusedBeforeStateChanges(t *testing.T) { |
| 166 | for name, mutate := range map[string]func(*ClaimLaunchInputs){ |
| 167 | "extra arg": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "--x", "y") }, |
| 168 | "duplicate flag": func(in *ClaimLaunchInputs) { in.Args = append(in.Args, "--db", in.DBPath) }, |
| 169 | "db mismatch": func(in *ClaimLaunchInputs) { in.Args[3] = in.DBPath + "-other" }, |
| 170 | "bad socket": func(in *ClaimLaunchInputs) { in.Args[1] = "/tmp/app.sock" }, |
| 171 | "no env": func(in *ClaimLaunchInputs) { in.Env = nil }, |
| 172 | "seed role": func(in *ClaimLaunchInputs) { in.Roles = []string{"main", "seed"} }, |
| 173 | } { |
| 174 | t.Run(name, func(t *testing.T) { |
| 175 | f := newRecoveryFixture(t) |
| 176 | c := f.controller("boot-1", "controller-1", 4101, "41") |
| 177 | c.wake.Roles = &ClaimRoleAssembly{Root: fakeLaunchRoot{protocol: hostbox.RoleLaunchProtocol}, ManagedRoots: map[string]string{"data": shapeData}, Manager: c.wake.Manager, Journal: f.journal} |
| 178 | route := registry.Route{Host: recoveryHost, Kind: "instance", Hibernate: true, |
| 179 | DBPath: provision.NestedDBPath(f.guard.DataDir, recoveryHost), Socket: provision.NestedSocket(recoveryHost)} |
| 180 | c.wake.Inputs = shapeMutatedInputs{recoveryInputs{f}, func(in *ClaimLaunchInputs) { |
| 181 | base := shapeInputs(recoveryHost) |
| 182 | base.DBPath = provision.NestedDBPath(shapeData, recoveryHost) |
| 183 | base.Args = []string{"--socket", provision.NestedSocket(recoveryHost), "--db", base.DBPath} |
| 184 | in.DBPath, in.Args, in.Env, in.Limit = base.DBPath, base.Args, base.Env, cgroup.TierDefault |
| 185 | mutate(in) |
| 186 | }} |
| 187 | before := f.read() |
| 188 | guardBefore, err := os.ReadDir(filepath.Join(f.guard.DataDir, "hostguard")) |
| 189 | if err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | err = c.runtime.Prerequisites(t.Context(), route, before, recoveryPolicy) |
| 193 | if !errors.Is(err, hostbox.ErrRoleLaunchShape) && !errors.Is(err, hostbox.ErrUnsupported) { |
| 194 | t.Fatalf("accepted: %v", err) |
| 195 | } |
| 196 | if after := f.read(); after.ETag != before.ETag || after.Claim.Holder.WakeRetry != nil { |
| 197 | t.Fatalf("refusal changed claim or spent retry: %+v", after) |
| 198 | } |
| 199 | guardAfter, err := os.ReadDir(filepath.Join(f.guard.DataDir, "hostguard")) |
| 200 | if err != nil || !reflect.DeepEqual(guardBefore, guardAfter) || len(c.wake.pending) != 0 || len(f.requests) != 0 { |
| 201 | t.Fatalf("refusal reached guard/stop/lease: err=%v pending=%d requests=%d", err, len(c.wake.pending), len(f.requests)) |
| 202 | } |
| 203 | if _, err := f.journal.Read(t.Context(), recoveryTuple(before, c.local)); err == nil { |
| 204 | t.Fatal("refusal journaled a launch") |
| 205 | } |
| 206 | }) |
| 207 | } |
| 208 | } |
| 209 | |