| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "net" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/rootstate" |
| 14 | "golang.org/x/sys/unix" |
| 15 | ) |
| 16 | |
| 17 | func TestPlan14FlatSocketMigration(t *testing.T) { |
| 18 | for _, attack := range []string{"none", "replacement", "boot", "lost-rename", "archive-replacement", "erased"} { |
| 19 | t.Run(attack, func(t *testing.T) { |
| 20 | dir := t.TempDir() |
| 21 | source, archive := filepath.Join(dir, "old.sock"), filepath.Join(dir, "retained.sock") |
| 22 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | listener.SetUnlinkOnClose(false) |
| 27 | defer listener.Close() |
| 28 | id, err := captureClaimUnitSocket(source, "boot-one") |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | listener.Close() |
| 33 | boot := "boot-one" |
| 34 | switch attack { |
| 35 | case "erased": |
| 36 | if err := os.Remove(source); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | case "boot": |
| 40 | boot = "boot-two" |
| 41 | case "replacement": |
| 42 | if err := os.Rename(source, source+".old"); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if err := os.WriteFile(source, []byte("replacement"), 0600); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | case "lost-rename", "archive-replacement": |
| 49 | if err := os.Rename(source, archive); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if attack == "archive-replacement" { |
| 53 | if err := os.Rename(archive, archive+".old"); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if err := os.WriteFile(archive, []byte("replacement"), 0600); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | renames := 0 |
| 62 | err = parkClaimUnitSocketWith(source, archive, boot, id, func(sfd int, src string, dfd int, dst string, flags uint) error { |
| 63 | renames++ |
| 64 | return unix.Renameat2(sfd, src, dfd, dst, flags) |
| 65 | }) |
| 66 | if attack == "none" || attack == "lost-rename" || attack == "erased" { |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if err = parkClaimUnitSocket(source, archive, boot, id); err != nil { |
| 71 | t.Fatal("replay", err) |
| 72 | } |
| 73 | if _, err := os.Lstat(source); !os.IsNotExist(err) { |
| 74 | t.Fatal("old socket still occupies routing name", err) |
| 75 | } |
| 76 | } else { |
| 77 | if err == nil { |
| 78 | t.Fatal("unowned socket accepted") |
| 79 | } |
| 80 | if renames != 0 { |
| 81 | t.Fatal("unowned socket renamed", renames) |
| 82 | } |
| 83 | } |
| 84 | }) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestPlan14OperatorSocketMigrationDurableRetry(t *testing.T) { |
| 89 | for _, loss := range []string{"stop", "park", "replace"} { |
| 90 | t.Run(loss, func(t *testing.T) { |
| 91 | b, in, shape := plan14UnitBindingFixture(t, "woodstar.service", "", "/run/carlos/woodstar.sock") |
| 92 | old := []byte("[Service]\nExecStart=/old/program\n") |
| 93 | shape.Program, shape.PredecessorDigest = strings.Repeat("a", 64), claimUnitDigest(old) |
| 94 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| 95 | binding, err := b.establish(t.Context(), in.Host) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | dir := t.TempDir() |
| 100 | source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") |
| 101 | ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | ln.SetUnlinkOnClose(false) |
| 106 | defer ln.Close() |
| 107 | want, err := captureClaimUnitSocket(source, "boot") |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | files := &plan14UnitFiles{body: old, id: plan14UnitIdentity(2)} |
| 112 | state := &rollbackMemory{files: map[string][]byte{}} |
| 113 | p := claimUnitPredecessor{Unit: shape.Unit, Invocation: "old", Group: "system.slice/woodstar.service", Boot: "boot", GroupIdentity: rootstate.FileIdentity{Device: 3, Inode: 4}} |
| 114 | stopped, parked := false, false |
| 115 | m := claimUnitMaterializer{state: state, read: files.read, create: files.create, idle: func(string) error { |
| 116 | if !stopped { |
| 117 | return os.ErrPermission |
| 118 | } |
| 119 | return nil |
| 120 | }} |
| 121 | m.predecessor = func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) { return p, nil } |
| 122 | m.socketCapture = func(context.Context, claimUnitShape, claimUnitPredecessor) (claimUnitSocketIdentity, error) { |
| 123 | return want, nil |
| 124 | } |
| 125 | m.stopPredecessor = func(context.Context, claimUnitPredecessor) error { |
| 126 | r, err := m.load(in.Host) |
| 127 | if err != nil || r.Migration == nil || r.Migration.Socket != want { |
| 128 | t.Fatal("stop without durable socket identity", err) |
| 129 | } |
| 130 | stopped = true |
| 131 | if loss == "stop" { |
| 132 | return os.ErrClosed |
| 133 | } |
| 134 | return nil |
| 135 | } |
| 136 | m.socketPark = func(_ claimUnitBinding, id claimUnitSocketIdentity) error { |
| 137 | if !stopped || id != want { |
| 138 | t.Fatal("socket park before exact predecessor stop") |
| 139 | } |
| 140 | if err := parkClaimUnitSocket(source, archive, "boot", id); err != nil { |
| 141 | return err |
| 142 | } |
| 143 | parked = true |
| 144 | if loss == "park" { |
| 145 | return os.ErrClosed |
| 146 | } |
| 147 | return nil |
| 148 | } |
| 149 | m.replace = func(unit string, id rootstate.FileIdentity, raw []byte) error { |
| 150 | if !parked { |
| 151 | t.Fatal("unit replaced before socket migration") |
| 152 | } |
| 153 | if err := files.replace(unit, id, raw); err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if loss == "replace" { |
| 157 | return os.ErrClosed |
| 158 | } |
| 159 | return nil |
| 160 | } |
| 161 | if err := m.install(t.Context(), binding); err == nil { |
| 162 | t.Fatal("lost reply accepted") |
| 163 | } |
| 164 | loss = "" |
| 165 | if err := m.install(t.Context(), binding); err != nil { |
| 166 | t.Fatal("durable retry", err) |
| 167 | } |
| 168 | if err := m.verify(binding); err != nil || files.retires != 1 { |
| 169 | t.Fatal("migration did not finish exactly once", err, files.retires) |
| 170 | } |
| 171 | }) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestPlan14FlatSocketMigrationRenameRace(t *testing.T) { |
| 176 | for _, occupied := range []bool{false, true} { |
| 177 | t.Run(map[bool]string{false: "restore", true: "occupied"}[occupied], func(t *testing.T) { |
| 178 | dir := t.TempDir() |
| 179 | source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") |
| 180 | ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | ln.SetUnlinkOnClose(false) |
| 185 | defer ln.Close() |
| 186 | id, err := captureClaimUnitSocket(source, "boot") |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | err = parkClaimUnitSocketWith(source, archive, "boot", id, func(sfd int, src string, dfd int, dst string, flags uint) error { |
| 191 | if err := os.Rename(source, source+".old"); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if err := os.WriteFile(source, []byte("raced"), 0600); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | err := unix.Renameat2(sfd, src, dfd, dst, flags) |
| 198 | if occupied && err == nil { |
| 199 | err = os.WriteFile(source, []byte("newest"), 0600) |
| 200 | } |
| 201 | return err |
| 202 | }) |
| 203 | if err == nil { |
| 204 | t.Fatal("raced replacement accepted") |
| 205 | } |
| 206 | path, want := source, "raced" |
| 207 | if occupied { |
| 208 | path, want = archive, "raced" |
| 209 | got, e := os.ReadFile(source) |
| 210 | if e != nil || string(got) != "newest" { |
| 211 | t.Fatal("new source overwritten", e) |
| 212 | } |
| 213 | } |
| 214 | got, err := os.ReadFile(path) |
| 215 | if err != nil || string(got) != want { |
| 216 | t.Fatal("raced file lost", string(got), err) |
| 217 | } |
| 218 | }) |
| 219 | } |
| 220 | } |
| 221 | |