| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "os/exec" |
| 11 | "path/filepath" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostguard" |
| 15 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 16 | "github.com/carlosframework/platform/internal/rootstate" |
| 17 | ) |
| 18 | |
| 19 | func TestPlan14RootMigrationRootRunner(t *testing.T) { |
| 20 | for _, point := range []string{"rename", "parent-sync"} { |
| 21 | for _, after := range []bool{false, true} { |
| 22 | for cut := 1; cut <= 8; cut++ { |
| 23 | t.Run(fmt.Sprintf("%s/%t/%d", point, after, cut), func(t *testing.T) { |
| 24 | m, s, _ := migrationFixture(t) |
| 25 | dir := generationRootRunnerDir(t) |
| 26 | open := func(name string) rootstate.Store { |
| 27 | state, err := rootstate.Open(filepath.Join(dir, name), true, false) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | return state |
| 32 | } |
| 33 | state, execution, fences := open("migration"), open("exec"), open("fences") |
| 34 | defer execution.Close() |
| 35 | m.state = state |
| 36 | m.execution = execution |
| 37 | s.Items = []rootMigrationItem{{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1}, Session: 9, ClaimETag: "claim"}} |
| 38 | n := 0 |
| 39 | injected := errors.New("interrupted migration syscall") |
| 40 | fault := func(_ string, stage string, op func() error) error { |
| 41 | if stage != point { |
| 42 | return op() |
| 43 | } |
| 44 | n++ |
| 45 | if n != cut { |
| 46 | return op() |
| 47 | } |
| 48 | if after { |
| 49 | if err := op(); err != nil { |
| 50 | return err |
| 51 | } |
| 52 | } |
| 53 | return injected |
| 54 | } |
| 55 | m.state = rootstate.WithWriteFault(state, fault) |
| 56 | root := hostguard.NewRootAuthority(rootstate.WithWriteFault(fences, fault)) |
| 57 | m.deny = func(ctx context.Context, item rootMigrationItem) error { |
| 58 | return root.RetainMigrationDeny(ctx, item.Installation, item.Session) |
| 59 | } |
| 60 | if err := m.run(t.Context()); !errors.Is(err, injected) { |
| 61 | t.Fatal("fault not reached", n, err) |
| 62 | } |
| 63 | state.Close() |
| 64 | fences.Close() |
| 65 | // Reopen real checked descriptors; the service-owned data directory is gone. |
| 66 | state = open("migration") |
| 67 | fences = open("fences") |
| 68 | defer state.Close() |
| 69 | defer fences.Close() |
| 70 | m.state = state |
| 71 | root = hostguard.NewRootAuthority(fences) |
| 72 | if err := m.run(t.Context()); err != nil { |
| 73 | t.Fatal("resume", err) |
| 74 | } |
| 75 | c, err := (rootparticipation.Ledger{State: execution}).Census() |
| 76 | if err != nil || !c.Complete { |
| 77 | t.Fatal("no complete census", c, err) |
| 78 | } |
| 79 | view, err := root.View(t.Context(), "a.example", hostguard.Captured{Incarnation: "inc", Stamp: 1}) |
| 80 | if err != nil || len(view.MigrationDenies) != 1 || view.MigrationDenies[0].Session != 9 { |
| 81 | t.Fatal("lost old session deny", view, err) |
| 82 | } |
| 83 | }) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestPlan14RootMigrationMaskRootRunner(t *testing.T) { |
| 90 | if os.Geteuid() != 0 { |
| 91 | t.Fatal("root required") |
| 92 | } |
| 93 | unit := "/usr/lib/systemd/system/carlos-edge.service" |
| 94 | for _, path := range []string{unit, "/etc/systemd/system/carlos-edge.service", "/run/systemd/system/carlos-edge.service"} { |
| 95 | if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| 96 | t.Fatal("existing edge unit", path, err) |
| 97 | } |
| 98 | } |
| 99 | if err := os.WriteFile(unit, []byte("[Unit]\nDescription=Migration test\n[Service]\nType=oneshot\nExecStart=/usr/bin/true\n[Install]\nWantedBy=multi-user.target\n"), 0644); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | run := func(args ...string) { |
| 103 | t.Helper() |
| 104 | if out, err := exec.Command("systemctl", args...).CombinedOutput(); err != nil { |
| 105 | t.Fatalf("systemctl %v: %v %s", args, err, out) |
| 106 | } |
| 107 | } |
| 108 | t.Cleanup(func() { |
| 109 | exec.Command("systemctl", "unmask", "--runtime", "carlos-edge.service").Run() |
| 110 | os.Remove(unit) |
| 111 | exec.Command("systemctl", "daemon-reload").Run() |
| 112 | }) |
| 113 | run("daemon-reload") |
| 114 | oldOwner, oldMarker := claimQuiesceOwner, claimEdgeMaskOwner |
| 115 | oldSystemctl := claimSystemctl |
| 116 | claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| 117 | out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| 118 | if err != nil { |
| 119 | t.Logf("systemctl %v: %v %s", args, err, out) |
| 120 | } |
| 121 | return out, err |
| 122 | } |
| 123 | t.Cleanup(func() { claimSystemctl = oldSystemctl }) |
| 124 | claimQuiesceOwner = func() (int, int, error) { return 65534, 65534, nil } |
| 125 | claimEdgeMaskOwner = filepath.Join(generationRootRunnerDir(t), "edge-mask") |
| 126 | t.Cleanup(func() { claimQuiesceOwner = oldOwner; claimEdgeMaskOwner = oldMarker }) |
| 127 | // An /etc unit takes precedence over a runtime mask. Refuse that conflict; |
| 128 | // a lower-priority fixture then exercises the existing mask protocol itself. |
| 129 | etcUnit := "/etc/systemd/system/carlos-edge.service" |
| 130 | body, err := os.ReadFile(unit) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := os.WriteFile(etcUnit, body, 0644); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | t.Cleanup(func() { os.Remove(etcUnit) }) |
| 138 | run("daemon-reload") |
| 139 | run("mask", "--runtime", "carlos-edge.service") |
| 140 | conflict, _, _ := migrationFixture(t) |
| 141 | conflict.quiesce = quiesceLegacyClaimJournals |
| 142 | if conflict.run(t.Context()) == nil { |
| 143 | t.Fatal("ineffective runtime mask allowed migration") |
| 144 | } |
| 145 | if err := os.Remove(etcUnit); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | run("daemon-reload") |
| 149 | // The operator owns this mask. Both failed and complete migration retain it. |
| 150 | run("mask", "--runtime", "carlos-edge.service") |
| 151 | before, err := os.Lstat(claimEdgeRuntimeMask) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | for _, fail := range []bool{true, false} { |
| 156 | m, _, _ := migrationFixture(t) |
| 157 | m.quiesce = quiesceLegacyClaimJournals |
| 158 | if fail { |
| 159 | m.ready = func(context.Context, rootMigrationSnapshot) error { return os.ErrPermission } |
| 160 | } |
| 161 | err := m.run(t.Context()) |
| 162 | if (err != nil) != fail { |
| 163 | t.Fatal("migration", fail, err) |
| 164 | } |
| 165 | after, err := os.Lstat(claimEdgeRuntimeMask) |
| 166 | if err != nil || !os.SameFile(before, after) { |
| 167 | t.Fatal("operator mask changed", err) |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | |