| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "reflect" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func TestClaimStagingBootstrapIgnoresUnreadableLiveEnvironment(t *testing.T) { |
| 14 | t.Setenv("CARLOS_HOST_CLAIM", "") |
| 15 | fallback := filepath.Join(t.TempDir(), "host.env") |
| 16 | if err := os.WriteFile(fallback, []byte("CARLOS_HOST_CLAIM=1\n"), 0000); err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | original, read := claimBootstrapHostEnv, readClaimUnitFile |
| 20 | claimBootstrapHostEnv = fallback |
| 21 | reads := 0 |
| 22 | readClaimUnitFile = func(path string) ([]byte, error) { |
| 23 | if path == fallback { |
| 24 | reads++ |
| 25 | return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} |
| 26 | } |
| 27 | return read(path) |
| 28 | } |
| 29 | t.Cleanup(func() { claimBootstrapHostEnv, readClaimUnitFile = original, read }) |
| 30 | root := t.TempDir() |
| 31 | if err := bootstrap(root, nil); err != nil { |
| 32 | t.Fatalf("flag-off staging read live environment: %v", err) |
| 33 | } |
| 34 | if reads != 0 { |
| 35 | t.Fatalf("staging read live environment %d times", reads) |
| 36 | } |
| 37 | got, err := os.ReadFile(filepath.Join(root, "etc/systemd/system", claimControlUnit)) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | want, err := unitsFS.ReadFile("units/" + claimControlUnit) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if !bytes.Equal(got, want) { |
| 46 | t.Fatal("flag-off staging changed the unit") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestClaimControlUnitInstallers(t *testing.T) { |
| 51 | legacy, err := unitsFS.ReadFile("units/" + claimControlUnit) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | for _, flag := range []string{"", " ", "1", "0"} { |
| 56 | t.Run("flag="+flag, func(t *testing.T) { |
| 57 | t.Setenv("CARLOS_HOST_CLAIM", flag) |
| 58 | enabled := strings.TrimSpace(flag) != "" |
| 59 | check := func(path string) { |
| 60 | t.Helper() |
| 61 | got, err := os.ReadFile(path) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if !enabled { |
| 66 | if !bytes.Equal(got, legacy) { |
| 67 | t.Fatal("flag-off unit differs from main") |
| 68 | } |
| 69 | return |
| 70 | } |
| 71 | props := map[string][]string{} |
| 72 | for _, line := range strings.Split(string(got), "\n") { |
| 73 | k, v, ok := strings.Cut(line, "=") |
| 74 | if ok { |
| 75 | props[k] = append(props[k], v) |
| 76 | } |
| 77 | } |
| 78 | for key, want := range map[string][]string{ |
| 79 | "EnvironmentFile": {"-/etc/carlos/host.env"}, |
| 80 | "StateDirectory": {"carlos-claims"}, |
| 81 | "StateDirectoryMode": {"0700"}, |
| 82 | "ReadWritePaths": {"/run/carlos-control /run/carlos /var/lib/carlos-claims /var/lib/carlos-release/private /run/systemd/system /data /opt/carlos /etc/systemd/system /sys/fs/cgroup"}, |
| 83 | "RestrictAddressFamilies": {"AF_UNIX AF_INET AF_INET6"}, |
| 84 | "ProtectSystem": {"strict"}, |
| 85 | "ProtectHome": {"yes"}, |
| 86 | } { |
| 87 | if !reflect.DeepEqual(props[key], want) { |
| 88 | t.Errorf("%s = %q, want %q", key, props[key], want) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | writeEnv := func(etc string) { |
| 93 | t.Helper() |
| 94 | if err := os.MkdirAll(filepath.Join(etc, "carlos"), 0755); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if err := os.WriteFile(filepath.Join(etc, "carlos", "host.env"), []byte("CARLOS_HOST_CLAIM=\""+flag+"\"\n"), 0600); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | } |
| 101 | stage := t.TempDir() |
| 102 | writeEnv(filepath.Join(stage, "etc")) |
| 103 | if enabled { |
| 104 | t.Setenv("CARLOS_HOST_CLAIM", "") |
| 105 | } else { |
| 106 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 107 | } |
| 108 | if err := bootstrap(stage, nil); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | check(filepath.Join(stage, "etc/systemd/system", claimControlUnit)) |
| 112 | f := newApplyFixture(t, "stable", "v1.0.0") |
| 113 | writeEnv(f.a.etcDir) |
| 114 | if err := os.Remove(filepath.Join(stage, "etc/carlos/host.env")); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if err := bootstrap(stage, nil); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if err := f.a.installUnits(t.Context(), stage, nil); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | check(filepath.Join(f.a.etcDir, "systemd/system", claimControlUnit)) |
| 124 | if enabled && !strings.Contains(strings.Join(f.calls, "\n"), "systemctl try-restart "+claimControlUnit) { |
| 125 | t.Fatal("apply did not restart the control service") |
| 126 | } |
| 127 | |
| 128 | // Repair a running, armed box whose root unit still has the other mode. |
| 129 | etc := t.TempDir() |
| 130 | writeEnv(etc) |
| 131 | units := filepath.Join(etc, "systemd/system") |
| 132 | if err := os.MkdirAll(units, 0755); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | for _, name := range systemUnitFiles { |
| 136 | b, e := unitsFS.ReadFile("units/" + name) |
| 137 | if e != nil { |
| 138 | t.Fatal(e) |
| 139 | } |
| 140 | if name == claimControlUnit && !enabled { |
| 141 | b = append(append([]byte{}, b...), []byte("\n[Service]\nEnvironmentFile=-/etc/carlos/host.env\n")...) |
| 142 | } |
| 143 | if e = os.WriteFile(filepath.Join(units, name), b, 0644); e != nil { |
| 144 | t.Fatal(e) |
| 145 | } |
| 146 | } |
| 147 | var calls []string |
| 148 | ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil } |
| 149 | probe := func(args ...string) (string, error) { |
| 150 | // host.env here does not arm db reads, so the db-read socket |
| 151 | // is dark (dbReadsSocketWanted), as ensureSystemUnits keeps it. |
| 152 | if args[1] == dbReadSocketUnit { |
| 153 | if args[0] == "is-active" { |
| 154 | return "inactive\n", systemctlExit(3) |
| 155 | } |
| 156 | return "disabled\n", systemctlExit(1) |
| 157 | } |
| 158 | if args[0] == "is-active" { |
| 159 | return "active\n", nil |
| 160 | } |
| 161 | return "enabled\n", nil |
| 162 | } |
| 163 | lock := filepath.Join(t.TempDir(), "lock") |
| 164 | ensureSystemUnits(etc, lock, ctl, probe, t.Logf) |
| 165 | check(filepath.Join(units, claimControlUnit)) |
| 166 | if !strings.Contains(strings.Join(calls, "\n"), "try-restart "+claimControlUnit) { |
| 167 | t.Fatal("stale armed unit was not repaired") |
| 168 | } |
| 169 | calls = nil |
| 170 | ensureSystemUnits(etc, lock, ctl, probe, t.Logf) |
| 171 | if len(calls) != 0 { |
| 172 | t.Fatalf("unchanged unit was reinstalled: %v", calls) |
| 173 | } |
| 174 | }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestClaimControlUnitSettings(t *testing.T) { |
| 179 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 180 | t.Setenv("CARLOS_DATA_DIR", "/wrong") |
| 181 | t.Setenv("CARLOS_OPT_DIR", "/wrong") |
| 182 | path := filepath.Join(t.TempDir(), "host.env") |
| 183 | missing, err := readClaimUnitSettings(path) |
| 184 | if err != nil || missing.enabled || missing.data != "/data" || missing.opt != "/opt/carlos" { |
| 185 | t.Fatalf("missing env: %+v %v", missing, err) |
| 186 | } |
| 187 | |
| 188 | for _, tc := range []struct { |
| 189 | name, body string |
| 190 | fail bool |
| 191 | enabled bool |
| 192 | data, opt string |
| 193 | }{ |
| 194 | {"absent flag", "OTHER=value\n", false, false, "/data", "/opt/carlos"}, |
| 195 | {"quoted", "CARLOS_HOST_CLAIM='0'\nCARLOS_DATA_DIR=\"/srv/my data\"\nCARLOS_OPT_DIR=/srv/opt%box\n", false, true, "/srv/my data", "/srv/opt%box"}, |
| 196 | {"last assignment", "CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM=\" \"\n", false, false, "/data", "/opt/carlos"}, |
| 197 | {"multiline", "OTHER='line\nCARLOS_HOST_CLAIM=1\n'\n", true, false, "", ""}, |
| 198 | {"root", "CARLOS_HOST_CLAIM=1\nCARLOS_DATA_DIR=/\n", true, false, "", ""}, |
| 199 | {"relative", "CARLOS_HOST_CLAIM=1\nCARLOS_OPT_DIR=relative\n", true, false, "", ""}, |
| 200 | } { |
| 201 | t.Run(tc.name, func(t *testing.T) { |
| 202 | if err := os.WriteFile(path, []byte(tc.body), 0600); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | cfg, err := readClaimUnitSettings(path) |
| 206 | if (err != nil) != tc.fail { |
| 207 | t.Fatalf("settings: %+v %v", cfg, err) |
| 208 | } |
| 209 | if tc.fail { |
| 210 | return |
| 211 | } |
| 212 | if cfg.enabled != tc.enabled || cfg.data != tc.data || cfg.opt != tc.opt { |
| 213 | t.Fatalf("settings: %+v", cfg) |
| 214 | } |
| 215 | if tc.name == "quoted" { |
| 216 | raw, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 217 | got := string(claimUnitContent(claimControlUnit, raw, cfg)) |
| 218 | if !strings.Contains(got, `"/srv/my data" "/srv/opt%%box"`) { |
| 219 | t.Fatal(got) |
| 220 | } |
| 221 | } |
| 222 | }) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestClaimControlRestartFailureRemainsRepairable(t *testing.T) { |
| 227 | path := filepath.Join(t.TempDir(), claimControlUnit) |
| 228 | before, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 229 | after := claimUnitContent(claimControlUnit, before, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"}) |
| 230 | if err := os.WriteFile(path, after, 0644); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | sentinel := errors.New("restart failed") |
| 234 | calls := 0 |
| 235 | err := restartClaimControl(path, before, after, func(args ...string) (string, error) { |
| 236 | calls++ |
| 237 | if args[0] == "try-restart" { |
| 238 | return "", sentinel |
| 239 | } |
| 240 | return "", nil |
| 241 | }) |
| 242 | if !errors.Is(err, sentinel) || calls != 2 { |
| 243 | t.Fatalf("restart: %v calls=%d", err, calls) |
| 244 | } |
| 245 | got, err := os.ReadFile(path) |
| 246 | if err != nil || !bytes.Equal(got, before) { |
| 247 | t.Fatalf("not restored: %v", err) |
| 248 | } |
| 249 | if !claimControlChanged(got, after) { |
| 250 | t.Fatal("next repair would not retry") |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func TestClaimBadUnitSettingsStillRepairSystemUnits(t *testing.T) { |
| 255 | etc := t.TempDir() |
| 256 | if err := os.MkdirAll(filepath.Join(etc, "carlos"), 0755); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if err := os.WriteFile(filepath.Join(etc, "carlos", "host.env"), []byte("OTHER='multiline\n"), 0600); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | units := filepath.Join(etc, "systemd", "system") |
| 263 | if err := os.MkdirAll(units, 0755); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | old := []byte("old control unit") |
| 267 | if err := os.WriteFile(filepath.Join(units, claimControlUnit), old, 0644); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | var calls []string |
| 271 | ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil } |
| 272 | probe := func(args ...string) (string, error) { return "inactive\n", nil } |
| 273 | ensureSystemUnits(etc, filepath.Join(t.TempDir(), "lock"), ctl, probe, t.Logf) |
| 274 | got, err := os.ReadFile(filepath.Join(units, claimControlUnit)) |
| 275 | want, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 276 | if err != nil || !bytes.Equal(got, want) { |
| 277 | t.Fatal("control unit not restored to main on unknown settings") |
| 278 | } |
| 279 | for _, name := range systemUnitFiles { |
| 280 | if name != claimControlUnit { |
| 281 | if _, err := os.Stat(filepath.Join(units, name)); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | if !strings.Contains(strings.Join(calls, "\n"), "daemon-reload") { |
| 287 | t.Fatal("system units were not repaired") |
| 288 | } |
| 289 | if strings.Contains(strings.Join(calls, "\n"), "try-restart") { |
| 290 | t.Fatal("unknown settings restarted control service") |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | func TestPlan14RootRetryUnitDirectoryScope(t *testing.T) { |
| 295 | raw := []byte("[Service]\nReadWritePaths=/run/carlos-control /run/systemd/system\nRestrictAddressFamilies=AF_UNIX\nMemoryMax=64M\n") |
| 296 | legacy := claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"}) |
| 297 | if bytes.Contains(legacy, []byte(rootRetryProjection)) { |
| 298 | t.Fatal("no-census unit received E3 write access") |
| 299 | } |
| 300 | census := claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, rootRetry: true, data: "/data", opt: "/opt/carlos"}) |
| 301 | if !bytes.Contains(census, []byte(" "+rootRetryProjection+"\n")) { |
| 302 | t.Fatal("root retry projection is read-only in the controller") |
| 303 | } |
| 304 | if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{rootRetry: true}); !bytes.Equal(got, raw) { |
| 305 | t.Fatal("disabled claims changed the unit") |
| 306 | } |
| 307 | } |
| 308 | |