| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "crypto/rand" |
| 10 | "crypto/sha256" |
| 11 | "encoding/base64" |
| 12 | "encoding/hex" |
| 13 | "encoding/json" |
| 14 | "errors" |
| 15 | "flag" |
| 16 | "os" |
| 17 | "path/filepath" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | "sync/atomic" |
| 21 | "testing" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/claimactivation" |
| 24 | "github.com/carlosframework/platform/internal/hostactors" |
| 25 | "github.com/carlosframework/platform/internal/hostfloor" |
| 26 | "github.com/carlosframework/platform/internal/publicrelease" |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | func artifactSHA(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| 31 | |
| 32 | // artifactWorld is a fresh deployment ready for sign-policy and |
| 33 | // publish-identity: roster initialized and configured, the console shipped |
| 34 | // and promoted with pointer digests on. |
| 35 | type artifactWorld struct { |
| 36 | r claimactivation.Runner |
| 37 | store release.Store |
| 38 | pub ed25519.PublicKey |
| 39 | router []byte |
| 40 | sums string |
| 41 | sig string |
| 42 | } |
| 43 | |
| 44 | func newArtifactWorld(t *testing.T) *artifactWorld { |
| 45 | t.Helper() |
| 46 | ctx := t.Context() |
| 47 | store := release.NewDirStore(t.TempDir()) |
| 48 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 49 | sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) |
| 50 | w := &artifactWorld{store: store, pub: pub, router: []byte("carlos v0.28.2"), |
| 51 | r: claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}} |
| 52 | w.run(t, claimactivation.Plan{Phase: "initialize"}) |
| 53 | w.run(t, claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}}) |
| 54 | if err := release.WriteDigestMarker(ctx, store, w.r.Signer, "bab", "test"); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | m := release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}} |
| 58 | if err := release.Ship(ctx, store, "bab", m, map[string][]byte{"linux-arm64": []byte("console build")}); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if err := w.r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | w.sums = artifactSHA(w.router) + " carlos-linux-arm64\n" + artifactSHA([]byte("amd64 deb")) + " carlos_0.28.2_amd64.deb\n" + artifactSHA([]byte("arm64 deb")) + " carlos_0.28.2_arm64.deb\n" |
| 65 | w.sig = base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(w.sums))) |
| 66 | return w |
| 67 | } |
| 68 | |
| 69 | // plan fills in the bucket and the current roster revision. |
| 70 | func (w *artifactWorld) plan(t *testing.T, p claimactivation.Plan) claimactivation.Plan { |
| 71 | t.Helper() |
| 72 | p.Bucket = "bucket" |
| 73 | if o, err := w.r.Directory.Read(t.Context()); err == nil { |
| 74 | p.ExpectedRevision = o.Roster.Revision |
| 75 | } |
| 76 | return p |
| 77 | } |
| 78 | |
| 79 | func (w *artifactWorld) run(t *testing.T, p claimactivation.Plan) claimactivation.Result { |
| 80 | t.Helper() |
| 81 | result, err := w.r.Run(t.Context(), w.plan(t, p), false) |
| 82 | if err != nil { |
| 83 | t.Fatal(p.Phase, err) |
| 84 | } |
| 85 | return result |
| 86 | } |
| 87 | |
| 88 | func (w *artifactWorld) policyPlan(t *testing.T) claimactivation.Plan { |
| 89 | floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| 90 | return w.plan(t, claimactivation.Plan{Phase: "sign-policy", Floor: &floor}) |
| 91 | } |
| 92 | |
| 93 | func (w *artifactWorld) identityPlan(t *testing.T) claimactivation.Plan { |
| 94 | return w.plan(t, claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: w.sums, SumsSignature: w.sig, |
| 95 | Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}) |
| 96 | } |
| 97 | |
| 98 | // artifactPhases runs sign-policy and publish-identity on a fresh deployment |
| 99 | // and returns their outputs, the deployment key and the router build's hash. |
| 100 | func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) { |
| 101 | t.Helper() |
| 102 | w := newArtifactWorld(t) |
| 103 | policy = w.run(t, w.policyPlan(t)).Output |
| 104 | identity = w.run(t, w.identityPlan(t)).Output |
| 105 | return policy, identity, w.pub, w.router |
| 106 | } |
| 107 | |
| 108 | // writeOut reserves name under dir and writes raw to it, as claims run |
| 109 | // --out does. |
| 110 | func writeOut(dir *os.File, name string, raw []byte, dry bool) error { |
| 111 | out, err := reserveActivationOutputAt(dir, name, dry) |
| 112 | if err != nil { |
| 113 | return err |
| 114 | } |
| 115 | err = out.finish(raw) |
| 116 | out.close() |
| 117 | return err |
| 118 | } |
| 119 | |
| 120 | func openDir(t *testing.T) (*os.File, string) { |
| 121 | t.Helper() |
| 122 | path := t.TempDir() |
| 123 | dir, err := os.Open(path) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | t.Cleanup(func() { dir.Close() }) |
| 128 | return dir, path |
| 129 | } |
| 130 | |
| 131 | // C4: the phases' files are written byte for byte, 0600 and exclusively, |
| 132 | // and guard-arm's own parse accepts exactly those bytes. A re-indented |
| 133 | // policy (what jq would print) is refused, which is why nothing may pass |
| 134 | // through jq. |
| 135 | func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) { |
| 136 | policy, identity, pub, router := artifactPhases(t) |
| 137 | dir, dirPath := openDir(t) |
| 138 | var err error |
| 139 | for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} { |
| 140 | if err = writeOut(dir, name, raw, true); err != nil { |
| 141 | t.Fatal("dry run", name, err) |
| 142 | } |
| 143 | if _, err = os.Lstat(filepath.Join(dirPath, name)); !os.IsNotExist(err) { |
| 144 | t.Fatal("dry run wrote", name, err) |
| 145 | } |
| 146 | if err = writeOut(dir, name, raw, false); err != nil { |
| 147 | t.Fatal(name, err) |
| 148 | } |
| 149 | st, err := os.Lstat(filepath.Join(dirPath, name)) |
| 150 | if err != nil || st.Mode() != 0o600 { |
| 151 | t.Fatal(name, st, err) |
| 152 | } |
| 153 | if err = writeOut(dir, name, raw, false); err == nil { |
| 154 | t.Fatal("replaced an existing", name) |
| 155 | } |
| 156 | if err = writeOut(dir, name, raw, true); err == nil { |
| 157 | t.Fatal("dry run accepted an existing", name) |
| 158 | } |
| 159 | } |
| 160 | policyFile, _ := os.ReadFile(filepath.Join(dirPath, "policy.json")) |
| 161 | identityFile, _ := os.ReadFile(filepath.Join(dirPath, "identity.json")) |
| 162 | if !bytes.Equal(policyFile, policy) || !bytes.Equal(identityFile, identity) { |
| 163 | t.Fatal("files are not the phase output") |
| 164 | } |
| 165 | p, f, err := guardArmInputs(policyFile, identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)) |
| 166 | if err != nil || p.Mode != "armed" || f.Floor.MinRelease != "v0.28.2" { |
| 167 | t.Fatal("guard-arm refused the written files", err) |
| 168 | } |
| 169 | var indented bytes.Buffer |
| 170 | if err = json.Indent(&indented, policyFile, "", " "); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | if _, _, err = guardArmInputs(indented.Bytes(), identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)); err == nil { |
| 174 | t.Fatal("guard-arm accepted a re-indented policy") |
| 175 | } |
| 176 | for name, args := range map[string][]string{ |
| 177 | "other build": {"v0.28.1", "linux-arm64", artifactSHA(router)}, |
| 178 | "other platform": {"v0.28.2", "linux-amd64", artifactSHA(router)}, |
| 179 | "other image": {"v0.28.2", "linux-arm64", strings.Repeat("0", 64)}, |
| 180 | } { |
| 181 | if _, _, err = guardArmInputs(policyFile, identityFile, pub, "bucket", args[0], args[1], args[2]); err == nil { |
| 182 | t.Fatal("guard-arm accepted", name) |
| 183 | } |
| 184 | } |
| 185 | for _, name := range []string{"", ".", "..", "a/b"} { |
| 186 | if err = writeOut(dir, name, policy, false); err == nil { |
| 187 | t.Fatalf("wrote %q", name) |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestConsoleInstallTargetNeedsThePublishedIdentity(t *testing.T) { |
| 193 | _, identity, pub, _ := artifactPhases(t) |
| 194 | tag, sha, err := consoleInstallTarget(identity, identity, pub, "v0.28.2", "linux-arm64") |
| 195 | if err != nil || tag != "v0.28.2" || sha != artifactSHA([]byte("console build")) { |
| 196 | t.Fatal(tag, sha, err) |
| 197 | } |
| 198 | other, _, _ := ed25519.GenerateKey(rand.Reader) |
| 199 | for name, tc := range map[string]struct { |
| 200 | staged, published []byte |
| 201 | pub ed25519.PublicKey |
| 202 | build, platform string |
| 203 | }{ |
| 204 | "not published": {identity, nil, pub, "v0.28.2", "linux-arm64"}, |
| 205 | "other bytes": {identity, append(append([]byte(nil), identity...), '\n'), pub, "v0.28.2", "linux-arm64"}, |
| 206 | "other key": {identity, identity, other, "v0.28.2", "linux-arm64"}, |
| 207 | "other build": {identity, identity, pub, "v0.28.1", "linux-arm64"}, |
| 208 | "other platform": {identity, identity, pub, "v0.28.2", "linux-amd64"}, |
| 209 | "not identity": {[]byte("{}"), []byte("{}"), pub, "v0.28.2", "linux-arm64"}, |
| 210 | } { |
| 211 | if _, _, err := consoleInstallTarget(tc.staged, tc.published, tc.pub, tc.build, tc.platform); err == nil { |
| 212 | t.Fatal("accepted", name) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestInstallConsoleRefusesWithoutRoot(t *testing.T) { |
| 218 | if os.Geteuid() == 0 { |
| 219 | t.Skip("runs as non-root") |
| 220 | } |
| 221 | src := filepath.Join(t.TempDir(), "console") |
| 222 | if err := os.WriteFile(src, []byte("console build"), 0o755); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | dir := filepath.Join(t.TempDir(), "protected") |
| 226 | for _, dry := range []bool{true, false} { |
| 227 | if _, err := installProtectedConsole(dir, src, artifactSHA([]byte("console build")), dry); err == nil { |
| 228 | t.Fatal("installed without root") |
| 229 | } |
| 230 | } |
| 231 | if _, err := os.Lstat(dir); !os.IsNotExist(err) { |
| 232 | t.Fatal("refusal created the directory", err) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) { |
| 237 | if os.Geteuid() == 0 { |
| 238 | t.Skip("runs as non-root") |
| 239 | } |
| 240 | body := []byte("console build") |
| 241 | src := filepath.Join(t.TempDir(), "console") |
| 242 | if err := os.WriteFile(src, body, 0o755); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | dir := filepath.Join(t.TempDir(), "protected") |
| 246 | for _, dry := range []bool{true, false} { |
| 247 | if _, err := upgradeProtectedConsole(dir, src, artifactSHA(body), artifactSHA([]byte("old")), dry); err == nil { |
| 248 | t.Fatal("upgraded without root") |
| 249 | } |
| 250 | } |
| 251 | if _, err := os.Lstat(dir); !os.IsNotExist(err) { |
| 252 | t.Fatal("refusal created the directory", err) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // consoleDigest refuses more than consoleMaxBytes, by size or by bytes read |
| 257 | // (a pipe reports size 0), and an upgrade refuses a source path inside the |
| 258 | // console directory before reading anything. |
| 259 | func TestConsoleDigestRefusesOversized(t *testing.T) { |
| 260 | t.Parallel() |
| 261 | big := filepath.Join(t.TempDir(), "big") |
| 262 | if err := os.WriteFile(big, nil, 0o755); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if err := os.Truncate(big, consoleMaxBytes+2); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | f, err := os.Open(big) |
| 269 | if err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | defer f.Close() |
| 273 | if _, err := consoleDigest(f); err == nil { |
| 274 | t.Fatal("hashed an oversized file") |
| 275 | } |
| 276 | r, w, err := os.Pipe() |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | defer r.Close() |
| 281 | go func() { |
| 282 | buf := make([]byte, 1<<20) |
| 283 | for n := int64(0); n <= consoleMaxBytes; n += int64(len(buf)) { |
| 284 | if _, err := w.Write(buf); err != nil { |
| 285 | break |
| 286 | } |
| 287 | } |
| 288 | w.Close() |
| 289 | }() |
| 290 | if _, err := consoleDigest(r); err == nil { |
| 291 | t.Fatal("hashed an oversized stream") |
| 292 | } |
| 293 | small := filepath.Join(t.TempDir(), "small") |
| 294 | if err := os.WriteFile(small, []byte("console build"), 0o755); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | s, err := os.Open(small) |
| 298 | if err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | defer s.Close() |
| 302 | if got, err := consoleDigest(s); err != nil || got != artifactSHA([]byte("console build")) { |
| 303 | t.Fatal(got, err) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | // --upgrade and --from are install-console's, and come together; anywhere |
| 308 | // else they are refused before any input is read, as unknown flags were. |
| 309 | func TestClaimsUpgradeFlagsBelongToInstallConsole(t *testing.T) { |
| 310 | sha := artifactSHA([]byte("old")) |
| 311 | for _, args := range [][]string{ |
| 312 | {"install-console", "--upgrade"}, |
| 313 | {"install-console", "--from", sha}, |
| 314 | {"install-console", "--upgrade=false", "--from", sha}, |
| 315 | {"install-console", "--upgrade", "--from", ""}, |
| 316 | {"install-console", "--upgrade=false"}, |
| 317 | {"inspect", "--upgrade", "--from", sha}, |
| 318 | {"console-report", "--from", sha}, |
| 319 | } { |
| 320 | var out bytes.Buffer |
| 321 | if err := runSystemClaims(args, &out); !errors.Is(err, errActivation) || out.Len() != 0 { |
| 322 | t.Fatal("accepted", args, err, out.String()) |
| 323 | } |
| 324 | } |
| 325 | parse := func(args ...string) *flag.FlagSet { |
| 326 | fs := flag.NewFlagSet("claims", flag.ContinueOnError) |
| 327 | fs.Bool("upgrade", false, "") |
| 328 | fs.String("from", "", "") |
| 329 | fs.String("source", "", "") |
| 330 | if err := fs.Parse(args); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | return fs |
| 334 | } |
| 335 | for _, tc := range []struct { |
| 336 | command string |
| 337 | args []string |
| 338 | upgrade bool |
| 339 | from string |
| 340 | refused bool |
| 341 | }{ |
| 342 | {"install-console", []string{"--source", "x"}, false, "", false}, |
| 343 | {"inspect", nil, false, "", false}, |
| 344 | {"install-console", []string{"--upgrade", "--from", sha}, true, sha, false}, |
| 345 | {"install-console", []string{"--upgrade"}, true, "", true}, |
| 346 | {"install-console", []string{"--from", sha}, false, sha, true}, |
| 347 | {"install-console", []string{"--upgrade=false"}, false, "", true}, |
| 348 | {"install-console", []string{"--upgrade", "--from", ""}, true, "", true}, |
| 349 | {"inspect", []string{"--upgrade", "--from", sha}, true, sha, true}, |
| 350 | {"install-control", []string{"--from", sha}, false, sha, true}, |
| 351 | {"install-control", []string{"--upgrade"}, true, "", true}, |
| 352 | } { |
| 353 | if got := upgradeFlagsRefused(tc.command, parse(tc.args...), tc.upgrade, tc.from); got != tc.refused { |
| 354 | t.Fatal(tc.command, tc.args, "refused", got) |
| 355 | } |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // claims run verifies release checksum lists with the vendor key the |
| 360 | // binary embeds, never a key from its inputs. |
| 361 | func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) { |
| 362 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 363 | r := activationRunner(release.NewDirStore(t.TempDir()), pub, "bucket", key) |
| 364 | if r.SumsKey != publicrelease.Default().PubKey || r.SumsKey != "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" { |
| 365 | t.Fatal("sums key", r.SumsKey) |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) { |
| 370 | for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]\n", "[--out FILE]"} { |
| 371 | if !strings.Contains(activationClaimsUsage, want) { |
| 372 | t.Fatal("usage lacks", want) |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | // gatedStore holds its first Put until release is closed, after telling |
| 378 | // reached, and counts Puts. |
| 379 | type gatedStore struct { |
| 380 | release.Store |
| 381 | reached, release chan struct{} |
| 382 | once sync.Once |
| 383 | puts atomic.Int32 |
| 384 | } |
| 385 | |
| 386 | func (s *gatedStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| 387 | s.puts.Add(1) |
| 388 | s.once.Do(func() { close(s.reached); <-s.release }) |
| 389 | return s.Store.Put(ctx, key, raw, etag) |
| 390 | } |
| 391 | |
| 392 | // F4: two invocations naming the same --out. The first has reserved the |
| 393 | // path and is inside its identity PUT; the second fails its reservation |
| 394 | // before its phase runs and sends the bucket nothing. The first then |
| 395 | // finishes and the file is its published identity. |
| 396 | func TestActivationOutputCompetingInvocations(t *testing.T) { |
| 397 | w := newArtifactWorld(t) |
| 398 | dir, dirPath := openDir(t) |
| 399 | winner := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} |
| 400 | loser := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} |
| 401 | close(loser.release) |
| 402 | plan := w.identityPlan(t) |
| 403 | invoke := func(store *gatedStore) (claimactivation.Result, *putAttempts, error) { |
| 404 | attempts := &putAttempts{Store: store} |
| 405 | out, err := reserveActivationOutputAt(dir, "identity.json", false) |
| 406 | if err != nil { |
| 407 | return claimactivation.Result{}, attempts, err |
| 408 | } |
| 409 | result, err := runProducingPhase(t.Context(), w.r, attempts, plan, out, false) |
| 410 | return result, attempts, err |
| 411 | } |
| 412 | type outcome struct { |
| 413 | result claimactivation.Result |
| 414 | err error |
| 415 | } |
| 416 | done := make(chan outcome) |
| 417 | go func() { |
| 418 | result, _, err := invoke(winner) |
| 419 | done <- outcome{result, err} |
| 420 | }() |
| 421 | <-winner.reached |
| 422 | if _, attempts, err := invoke(loser); err == nil || attempts.attempted.Load() || loser.puts.Load() != 0 { |
| 423 | t.Fatal("the loser published or was not refused", err, loser.puts.Load()) |
| 424 | } |
| 425 | close(winner.release) |
| 426 | got := <-done |
| 427 | if got.err != nil { |
| 428 | t.Fatal(got.err) |
| 429 | } |
| 430 | file, err := os.ReadFile(filepath.Join(dirPath, "identity.json")) |
| 431 | stored, _, serr := w.store.Get(t.Context(), "control/system/router/releases/v0.28.2.json") |
| 432 | if err != nil || serr != nil || !bytes.Equal(file, stored) || !bytes.Equal(file, got.result.Output) { |
| 433 | t.Fatal("winner's file is not the published identity", err, serr) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | // F5: a failure keeps the reservation, never removes it, and the error |
| 438 | // names it and asks for a new --out path. A failure before any bucket write |
| 439 | // says only that. Nothing is removed even when the entry was replaced: the |
| 440 | // write refuses and the replacement stays. |
| 441 | func TestActivationOutputFailureKeepsTheReservation(t *testing.T) { |
| 442 | w := newArtifactWorld(t) |
| 443 | dir, dirPath := openDir(t) |
| 444 | path := filepath.Join(dirPath, "policy.json") |
| 445 | stale := w.policyPlan(t) |
| 446 | stale.ExpectedRevision-- |
| 447 | out, err := reserveOutput(dir, "policy.json", path, false) |
| 448 | if err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | attempts := &putAttempts{Store: w.store} |
| 452 | _, err = runProducingPhase(t.Context(), w.r, attempts, stale, out, false) |
| 453 | if err == nil || attempts.attempted.Load() || !strings.HasSuffix(err.Error(), "; "+path+" was kept. run it again with a new --out path") { |
| 454 | t.Fatal("stale plan", err) |
| 455 | } |
| 456 | if st, err := os.Lstat(path); err != nil || st.Size() != 0 || st.Mode() != 0o600 { |
| 457 | t.Fatal("reservation not kept", st, err) |
| 458 | } |
| 459 | if _, err = reserveOutput(dir, "policy.json", path, false); err == nil { |
| 460 | t.Fatal("rerun on the kept path accepted") |
| 461 | } |
| 462 | // The dry run reserves nothing, so it has nothing to keep. |
| 463 | other := filepath.Join(dirPath, "dry.json") |
| 464 | out, err = reserveOutput(dir, "dry.json", other, true) |
| 465 | if err != nil { |
| 466 | t.Fatal(err) |
| 467 | } |
| 468 | if _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, stale, out, true); err == nil || strings.Contains(err.Error(), "was kept") { |
| 469 | t.Fatal("dry run", err) |
| 470 | } |
| 471 | if _, err = os.Lstat(other); !os.IsNotExist(err) { |
| 472 | t.Fatal("dry run created its output", err) |
| 473 | } |
| 474 | // A replaced entry: the write refuses and nothing is removed. |
| 475 | replaced := filepath.Join(dirPath, "identity.json") |
| 476 | out, err = reserveOutput(dir, "identity.json", replaced, false) |
| 477 | if err != nil { |
| 478 | t.Fatal(err) |
| 479 | } |
| 480 | if err = os.Rename(replaced, replaced+".moved"); err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | if err = os.WriteFile(replaced, []byte("someone else's"), 0o600); err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, w.policyPlan(t), out, false) |
| 487 | if err == nil || !strings.HasSuffix(err.Error(), replaced+" was kept. run it again with a new --out path") { |
| 488 | t.Fatal("finished into a replaced entry", err) |
| 489 | } |
| 490 | if b, err := os.ReadFile(replaced); err != nil || string(b) != "someone else's" { |
| 491 | t.Fatal("the replacement changed", err) |
| 492 | } |
| 493 | if _, err = os.Lstat(replaced + ".moved"); err != nil { |
| 494 | t.Fatal("the reservation was removed", err) |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | // failingReadback commits Puts to key and then fails every Get of key, as |
| 499 | // when a create reaches the bucket and its readback is lost. |
| 500 | type failingReadback struct { |
| 501 | release.Store |
| 502 | key string |
| 503 | sent atomic.Bool |
| 504 | } |
| 505 | |
| 506 | func (s *failingReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| 507 | tag, err := s.Store.Put(ctx, key, raw, etag) |
| 508 | if key == s.key { |
| 509 | s.sent.Store(true) |
| 510 | } |
| 511 | return tag, err |
| 512 | } |
| 513 | |
| 514 | func (s *failingReadback) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 515 | if key == s.key && s.sent.Load() { |
| 516 | return nil, "", errors.New("readback lost") |
| 517 | } |
| 518 | return s.Store.Get(ctx, key) |
| 519 | } |
| 520 | |
| 521 | // A failure after the identity reached the bucket keeps the reserved file |
| 522 | // and says so. |
| 523 | func TestActivationOutputKeptAfterPublication(t *testing.T) { |
| 524 | w := newArtifactWorld(t) |
| 525 | dir, dirPath := openDir(t) |
| 526 | out, err := reserveOutput(dir, "identity.json", filepath.Join(dirPath, "identity.json"), false) |
| 527 | if err != nil { |
| 528 | t.Fatal(err) |
| 529 | } |
| 530 | key := "control/system/router/releases/v0.28.2.json" |
| 531 | _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: &failingReadback{Store: w.store, key: key}}, w.identityPlan(t), out, false) |
| 532 | if err == nil || !strings.Contains(err.Error(), "the phase wrote to the bucket before it failed, so "+out.path+" was kept. run it again with a new --out path") { |
| 533 | t.Fatal("failure after publication", err) |
| 534 | } |
| 535 | if _, err = os.Lstat(out.path); err != nil { |
| 536 | t.Fatal("reservation removed after publication", err) |
| 537 | } |
| 538 | if _, _, err = w.store.Get(t.Context(), key); err != nil { |
| 539 | t.Fatal("identity not published", err) |
| 540 | } |
| 541 | } |
| 542 | |