| 1 | //go:build linux |
| 2 | |
| 3 | package activator |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/instancectl" |
| 15 | "github.com/carlosframework/platform/internal/instancekey" |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | "github.com/carlosframework/platform/internal/seeds" |
| 19 | ) |
| 20 | |
| 21 | // importedRoute is nestedBackend's row as an import's swap leaves it: a |
| 22 | // fresh seed id, swapped, its snapshot in the box's seed reader. |
| 23 | func importedRoute(t *testing.T, b *ExecBackend, rt registry.Route) registry.Route { |
| 24 | t.Helper() |
| 25 | id, _ := seeds.NewID() |
| 26 | snap := filepath.Join(t.TempDir(), "snap.db") |
| 27 | if err := writeMinimalSQLite(snap); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | body, _ := os.ReadFile(snap) |
| 31 | sum := sha256.Sum256(body) |
| 32 | rt.SeedSource = seeds.RowSeed{ID: id, Kind: seeds.KindImport, SHA256: hex.EncodeToString(sum[:]), Bytes: int64(len(body)), State: seeds.StateSwapped}.Encode() |
| 33 | b.Seeds = &fakeSeedObjects{body: map[string][]byte{id: body}} |
| 34 | return rt |
| 35 | } |
| 36 | |
| 37 | // launchKey is one launch of rt as doWake makes it (the restore, then the |
| 38 | // run role's preparation): the key the app is handed, and the key the |
| 39 | // instance's own env file holds ("" for none). |
| 40 | func launchKey(t *testing.T, b *ExecBackend, rt registry.Route) (handed, own string) { |
| 41 | t.Helper() |
| 42 | if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh { |
| 43 | t.Fatalf("Restore = fresh %v, %v", fresh, err) |
| 44 | } |
| 45 | _, ctl, logs, env, err := b.prepare(rt, instancectl.RoleRun) |
| 46 | if err != nil { |
| 47 | t.Fatalf("prepare: %v", err) |
| 48 | } |
| 49 | logs.Close() |
| 50 | if ctl != nil { |
| 51 | ctl.Close() |
| 52 | } |
| 53 | raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), rt.Host+".env")) |
| 54 | if err != nil && !os.IsNotExist(err) { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | return env[InstanceKeyVar], parseEnvBytes(raw)[InstanceKeyVar] |
| 58 | } |
| 59 | |
| 60 | // Merge review S1, the reported sequence: an import is verified (its first |
| 61 | // launch mints key A), aborted and rolled back; the owner then configures |
| 62 | // the old process's key B and imports the host again. The second import is |
| 63 | // handed B. Before the fix it was handed A, which the first import's |
| 64 | // rollback left in <host>.env, where it outranks configuration. |
| 65 | func TestAnAbortedImportsKeyDoesNotOutliveItsRollback(t *testing.T) { |
| 66 | noOwnReplica(t) |
| 67 | b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| 68 | dir := provision.InstanceDir(b.DataDir, rt.Host) |
| 69 | |
| 70 | // The swap, before the import's row exists: this host has no env file. |
| 71 | before, err := SeedEnvBefore(rt.DBPath, rt.Host) |
| 72 | if err != nil || before != registry.EnvBeforeAbsent { |
| 73 | t.Fatalf("SeedEnvBefore = %q, %v; want absent", before, err) |
| 74 | } |
| 75 | // Verify: the import's first launch mints key A. |
| 76 | first := importedRoute(t, b, rt) |
| 77 | keyA, ownA := launchKey(t, b, first) |
| 78 | if !wellFormedInstanceKey(keyA) || ownA != keyA { |
| 79 | t.Fatal("the import's first launch did not mint a key into its own env file") |
| 80 | } |
| 81 | // The adoption record a helper would have written for this writer. |
| 82 | if err := os.WriteFile(filepath.Join(dir, rt.Host+instancekey.AdoptionSuffix), []byte("{}\n"), 0o600); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | |
| 86 | // Abort: the fenced rollback's step 3, as seedwiring wires it. |
| 87 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 88 | t.Fatalf("RetireSeedKey: %v", err) |
| 89 | } |
| 90 | if err := PurgeSeedLocal(rt.DBPath); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | for _, n := range []string{rt.Host + ".env", rt.Host + instancekey.AdoptionSuffix} { |
| 94 | if _, err := os.Lstat(filepath.Join(dir, n)); !os.IsNotExist(err) { |
| 95 | t.Fatalf("%s survived the rollback (%v): it was the import's", n, err) |
| 96 | } |
| 97 | } |
| 98 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 99 | t.Fatalf("a second retirement: %v", err) |
| 100 | } |
| 101 | |
| 102 | // The owner configures the old process's key B; the config sync |
| 103 | // delivers it. |
| 104 | keyB := strings.Repeat("B", 43) + "=" |
| 105 | if err := os.WriteFile(filepath.Join(dir, rt.Host+".config.env"), []byte(InstanceKeyVar+"="+keyB+"\n"), 0o600); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | |
| 109 | // Reimport, same host: its swap finds no env file again, and its first |
| 110 | // launch is handed B. Nothing is minted while configuration sets it. |
| 111 | if before, err := SeedEnvBefore(rt.DBPath, rt.Host); err != nil || before != registry.EnvBeforeAbsent { |
| 112 | t.Fatalf("SeedEnvBefore at the reimport = %q, %v; want absent", before, err) |
| 113 | } |
| 114 | second := importedRoute(t, b, rt) |
| 115 | handed, own := launchKey(t, b, second) |
| 116 | if handed != keyB { |
| 117 | if handed == keyA { |
| 118 | t.Fatal("the reimport was handed the aborted import's key, not the configured one") |
| 119 | } |
| 120 | t.Fatalf("the reimport was handed a key that is not the configured one") |
| 121 | } |
| 122 | if own != "" { |
| 123 | t.Fatal("a key was minted into the own env file while configuration sets one") |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // Merge review S1: a key, and any env content, that predated the import |
| 128 | // survives its rollback. Only what the import created is retired. |
| 129 | func TestRetireSeedKeyLeavesWhatPredatesTheImport(t *testing.T) { |
| 130 | token := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("e", 64) |
| 131 | t.Run("a key that was there before", func(t *testing.T) { |
| 132 | noOwnReplica(t) |
| 133 | b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| 134 | dir := provision.InstanceDir(b.DataDir, rt.Host) |
| 135 | keyP := strings.Repeat("P", 43) + "=" |
| 136 | predating := []byte(token + "\n" + InstanceKeyVar + "=" + keyP + "\n") |
| 137 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | envPath := filepath.Join(dir, rt.Host+".env") |
| 141 | if err := os.WriteFile(envPath, predating, 0o600); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | adoption := filepath.Join(dir, rt.Host+instancekey.AdoptionSuffix) |
| 145 | if err := os.WriteFile(adoption, []byte("{}\n"), 0o600); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | before, err := SeedEnvBefore(rt.DBPath, rt.Host) |
| 149 | if err != nil || before != registry.EnvBeforeKey { |
| 150 | t.Fatalf("SeedEnvBefore = %q, %v; want key", before, err) |
| 151 | } |
| 152 | // The import launches with the key that was there: nothing minted. |
| 153 | if handed, _ := launchKey(t, b, importedRoute(t, b, rt)); handed != keyP { |
| 154 | t.Fatal("the import was not handed the key that predated it") |
| 155 | } |
| 156 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if err := PurgeSeedLocal(rt.DBPath); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if got, err := os.ReadFile(envPath); err != nil || string(got) != string(predating) { |
| 163 | t.Fatalf("the env file that predated the import changed: %v", err) |
| 164 | } |
| 165 | if _, err := os.Stat(adoption); err != nil { |
| 166 | t.Fatalf("the adoption record that predated the import went: %v", err) |
| 167 | } |
| 168 | }) |
| 169 | t.Run("an env file with no key", func(t *testing.T) { |
| 170 | noOwnReplica(t) |
| 171 | b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| 172 | dir := provision.InstanceDir(b.DataDir, rt.Host) |
| 173 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | envPath := filepath.Join(dir, rt.Host+".env") |
| 177 | // No trailing newline, a comment, and a line the platform does not own. |
| 178 | predating := "# set by hand\n" + token + "\nOTHER=1" |
| 179 | if err := os.WriteFile(envPath, []byte(predating), 0o600); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | adoption := filepath.Join(dir, rt.Host+instancekey.AdoptionSuffix) |
| 183 | if err := os.WriteFile(adoption, []byte("{}\n"), 0o600); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | before, err := SeedEnvBefore(rt.DBPath, rt.Host) |
| 187 | if err != nil || before != registry.EnvBeforeNoKey { |
| 188 | t.Fatalf("SeedEnvBefore = %q, %v; want no-key", before, err) |
| 189 | } |
| 190 | handed, own := launchKey(t, b, importedRoute(t, b, rt)) |
| 191 | if !wellFormedInstanceKey(handed) || own != handed { |
| 192 | t.Fatal("the import's launch did not mint a key into the file") |
| 193 | } |
| 194 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | got, err := os.ReadFile(envPath) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | // Every line that was there is there, in order; the key is not. |
| 202 | if string(got) != predating+"\n" { |
| 203 | t.Fatalf("after the retirement the file is not what predated the import plus its newline (%d bytes, want %d)", len(got), len(predating)+1) |
| 204 | } |
| 205 | if st, _ := os.Stat(envPath); st.Mode().Perm() != 0o600 { |
| 206 | t.Fatalf("mode = %o, want 600", st.Mode().Perm()) |
| 207 | } |
| 208 | if _, err := os.Stat(adoption); err != nil { |
| 209 | t.Fatalf("the adoption record went with a file that predated the import: %v", err) |
| 210 | } |
| 211 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if again, _ := os.ReadFile(envPath); string(again) != string(got) { |
| 215 | t.Fatal("a second retirement changed the file") |
| 216 | } |
| 217 | }) |
| 218 | t.Run("a journal row that does not say", func(t *testing.T) { |
| 219 | noOwnReplica(t) |
| 220 | b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| 221 | handed, own := launchKey(t, b, importedRoute(t, b, rt)) |
| 222 | if err := RetireSeedKey(rt.DBPath, rt.Host, ""); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if _, now := launchKeyFile(t, b, rt); now != own || own != handed { |
| 226 | t.Fatal("a key was retired on no record of what predated the import") |
| 227 | } |
| 228 | }) |
| 229 | t.Run("nothing there", func(t *testing.T) { |
| 230 | b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| 231 | for _, before := range []string{registry.EnvBeforeAbsent, registry.EnvBeforeNoKey, registry.EnvBeforeKey, ""} { |
| 232 | if err := RetireSeedKey(rt.DBPath, rt.Host, before); err != nil { |
| 233 | t.Fatalf("%q with no instance directory: %v", before, err) |
| 234 | } |
| 235 | } |
| 236 | if err := RetireSeedKey(rt.DBPath, rt.Host, "whatever"); err == nil { |
| 237 | t.Fatal("an unknown env state was accepted") |
| 238 | } |
| 239 | _ = b |
| 240 | }) |
| 241 | } |
| 242 | |
| 243 | // launchKeyFile reads the own env file's key without launching. |
| 244 | func launchKeyFile(t *testing.T, b *ExecBackend, rt registry.Route) (exists bool, key string) { |
| 245 | t.Helper() |
| 246 | raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), rt.Host+".env")) |
| 247 | if os.IsNotExist(err) { |
| 248 | return false, "" |
| 249 | } |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | return true, parseEnvBytes(raw)[InstanceKeyVar] |
| 254 | } |
| 255 | |
| 256 | // The swap does not go ahead on an env file it cannot read: a link there is |
| 257 | // refused, never followed, and never read as "no file". |
| 258 | func TestSeedEnvBeforeRefusesALink(t *testing.T) { |
| 259 | b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| 260 | dir := provision.InstanceDir(b.DataDir, rt.Host) |
| 261 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | target := filepath.Join(t.TempDir(), "elsewhere.env") |
| 265 | if err := os.WriteFile(target, []byte(InstanceKeyVar+"=x\n"), 0o600); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | if err := os.Symlink(target, filepath.Join(dir, rt.Host+".env")); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if before, err := SeedEnvBefore(rt.DBPath, rt.Host); err == nil { |
| 272 | t.Fatalf("SeedEnvBefore = %q over a symlink, want an error", before) |
| 273 | } |
| 274 | } |
| 275 | |