| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // systemctlExit is the shape of every answer the two probes get on a real |
| 13 | // box: systemd prints the state and exits non-zero to say which state it is |
| 14 | // — 3 for is-active's "inactive", 1 for is-enabled's "disabled". A fake that |
| 15 | // returned a nil error here would test a systemctl that does not exist. |
| 16 | func systemctlExit(status int) error { |
| 17 | return fmt.Errorf("exit status %d", status) |
| 18 | } |
| 19 | |
| 20 | func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| 21 | etc := t.TempDir() |
| 22 | os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| 23 | lockPath := filepath.Join(t.TempDir(), "lock") |
| 24 | var calls []string |
| 25 | systemctl := func(args ...string) (string, error) { |
| 26 | calls = append(calls, strings.Join(args, " ")) |
| 27 | return "", nil |
| 28 | } |
| 29 | probe := func(args ...string) (string, error) { |
| 30 | calls = append(calls, strings.Join(args, " ")) |
| 31 | switch args[0] { |
| 32 | case "is-active": |
| 33 | return "inactive\n", systemctlExit(3) |
| 34 | case "is-enabled": |
| 35 | return "disabled\n", systemctlExit(1) |
| 36 | } |
| 37 | return "", nil |
| 38 | } |
| 39 | ensureSystemUnits(etc, lockPath, systemctl, probe, t.Logf) |
| 40 | for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| 41 | if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err != nil { |
| 42 | t.Errorf("%s not installed", u) |
| 43 | } |
| 44 | } |
| 45 | joined := strings.Join(calls, "\n") |
| 46 | for _, want := range []string{"daemon-reload", "enable carlos-system-update.path carlos-system-recover.service", "start carlos-system-update.path"} { |
| 47 | if !strings.Contains(joined, want) { |
| 48 | t.Errorf("calls lack %q:\n%s", want, joined) |
| 49 | } |
| 50 | } |
| 51 | if strings.Contains(joined, "start carlos-system-recover.service") || strings.Contains(joined, "start carlos-system-update.service") { |
| 52 | t.Fatal("a service was started") |
| 53 | } |
| 54 | // All present and armed: no writes, no systemctl beyond the five checks |
| 55 | // (is-active x3: the self-update path unit, the control socket and the |
| 56 | // db-read socket; is-enabled x2: the recover service and the db-read |
| 57 | // socket). The db-read socket is wanted because host.env arms db reads. |
| 58 | dbReadsHostEnv(t, etc) |
| 59 | calls = nil |
| 60 | probe2 := func(args ...string) (string, error) { |
| 61 | calls = append(calls, strings.Join(args, " ")) |
| 62 | switch args[0] { |
| 63 | case "is-active": |
| 64 | return "active\n", nil |
| 65 | case "is-enabled": |
| 66 | return "enabled\n", nil |
| 67 | } |
| 68 | return "", nil |
| 69 | } |
| 70 | ensureSystemUnits(etc, lockPath, systemctl, probe2, t.Logf) |
| 71 | if len(calls) != 5 { |
| 72 | t.Fatalf("an armed box got %v", calls) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // The unit files are not on disk at all, which is what a box the roll script |
| 77 | // forgot looks like: is-enabled prints NOTHING and exits 1 for a unit file |
| 78 | // systemd cannot find. That silence is "not enabled", not "no systemctl". |
| 79 | func TestEnsureSystemUnitsArmsAUnitSystemdHasNeverHeardOf(t *testing.T) { |
| 80 | etc := t.TempDir() |
| 81 | lockPath := filepath.Join(t.TempDir(), "lock") |
| 82 | var calls []string |
| 83 | systemctl := func(args ...string) (string, error) { |
| 84 | calls = append(calls, strings.Join(args, " ")) |
| 85 | return "", nil |
| 86 | } |
| 87 | probe := func(args ...string) (string, error) { |
| 88 | switch args[0] { |
| 89 | case "is-active": |
| 90 | return "inactive\n", systemctlExit(3) |
| 91 | case "is-enabled": |
| 92 | return "", systemctlExit(1) |
| 93 | } |
| 94 | return "", nil |
| 95 | } |
| 96 | ensureSystemUnits(etc, lockPath, systemctl, probe, t.Logf) |
| 97 | for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| 98 | if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err != nil { |
| 99 | t.Errorf("%s not installed", u) |
| 100 | } |
| 101 | } |
| 102 | if !strings.Contains(strings.Join(calls, "\n"), "start carlos-system-update.path") { |
| 103 | t.Errorf("the path unit was never started: %v", calls) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // An enabled recover unit whose path unit is enabled but never started — |
| 108 | // the flagship's own failure — is armed, not left alone. |
| 109 | func TestEnsureSystemUnitsStartsAnEnabledButUnstartedPathUnit(t *testing.T) { |
| 110 | etc := t.TempDir() |
| 111 | unitDir := filepath.Join(etc, "systemd", "system") |
| 112 | os.MkdirAll(unitDir, 0o755) |
| 113 | for _, u := range systemUnitFiles { |
| 114 | os.WriteFile(filepath.Join(unitDir, u), []byte("[Unit]\n"), 0o644) |
| 115 | } |
| 116 | lockPath := filepath.Join(t.TempDir(), "lock") |
| 117 | var calls []string |
| 118 | systemctl := func(args ...string) (string, error) { |
| 119 | calls = append(calls, strings.Join(args, " ")) |
| 120 | return "", nil |
| 121 | } |
| 122 | probe := func(args ...string) (string, error) { |
| 123 | switch args[0] { |
| 124 | case "is-active": |
| 125 | return "inactive\n", systemctlExit(3) |
| 126 | case "is-enabled": |
| 127 | return "enabled\n", nil |
| 128 | } |
| 129 | return "", nil |
| 130 | } |
| 131 | ensureSystemUnits(etc, lockPath, systemctl, probe, t.Logf) |
| 132 | if !strings.Contains(strings.Join(calls, "\n"), "start carlos-system-update.path") { |
| 133 | t.Errorf("an enabled-but-inactive path unit was left unstarted: %v", calls) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestEnsureSystemUnitsLeavesAConcurrentApplyAlone(t *testing.T) { |
| 138 | etc := t.TempDir() |
| 139 | os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| 140 | lockPath := filepath.Join(t.TempDir(), "lock") |
| 141 | // Hold the lock as `carlos system apply` would. |
| 142 | held, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | defer held.Close() |
| 147 | if err := flockExclusiveNonblock(held); err != nil { |
| 148 | t.Skipf("flock unsupported on this platform: %v", err) |
| 149 | } |
| 150 | var calls []string |
| 151 | record := func(args ...string) (string, error) { |
| 152 | calls = append(calls, strings.Join(args, " ")) |
| 153 | return "", nil |
| 154 | } |
| 155 | ensureSystemUnits(etc, lockPath, record, record, t.Logf) |
| 156 | if len(calls) != 0 { |
| 157 | t.Fatalf("a locked-out pass called systemctl: %v", calls) |
| 158 | } |
| 159 | for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| 160 | if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err == nil { |
| 161 | t.Errorf("%s was installed by a locked-out pass", u) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // No systemctl at all: the probe prints no word, and a box we cannot ask is |
| 167 | // a box we do not touch. |
| 168 | func TestEnsureSystemUnitsTrustsNoBrokenSystemctl(t *testing.T) { |
| 169 | etc := t.TempDir() |
| 170 | os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| 171 | lockPath := filepath.Join(t.TempDir(), "lock") |
| 172 | systemctl := func(args ...string) (string, error) { |
| 173 | return "", errors.New("systemctl: command not found") |
| 174 | } |
| 175 | ensureSystemUnits(etc, lockPath, systemctl, systemctl, t.Logf) |
| 176 | for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| 177 | if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err == nil { |
| 178 | t.Errorf("%s was installed despite systemctl being unavailable", u) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestParseIsEnabled(t *testing.T) { |
| 184 | yes, no := true, false |
| 185 | for _, c := range []struct { |
| 186 | out string |
| 187 | want *bool |
| 188 | }{ |
| 189 | {"enabled\n", &yes}, |
| 190 | {"enabled-runtime\n", &yes}, |
| 191 | {"static\n", &yes}, |
| 192 | {"disabled\n", &no}, |
| 193 | {"masked\n", &no}, |
| 194 | {"", nil}, |
| 195 | {"something-systemd-invents\n", nil}, |
| 196 | } { |
| 197 | got := parseIsEnabled(c.out) |
| 198 | switch { |
| 199 | case got == nil && c.want != nil: |
| 200 | t.Errorf("parseIsEnabled(%q) = nil, want %v", c.out, *c.want) |
| 201 | case got != nil && c.want == nil: |
| 202 | t.Errorf("parseIsEnabled(%q) = %v, want nil", c.out, *got) |
| 203 | case got != nil && *got != *c.want: |
| 204 | t.Errorf("parseIsEnabled(%q) = %v, want %v", c.out, *got, *c.want) |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | |