| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "github.com/carlosframework/platform/internal/alerting" |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestPlan12ReportingFailureStillRunsRecovery(t *testing.T) { |
| 16 | f := newResolveFixture(t, true) |
| 17 | o, err := hostclaim.Update(t.Context(), f.raw, f.moving, func(c *hostclaim.Claim) error { |
| 18 | ref := hostclaim.FenceRef{Enrollment: strings.Repeat("a", 32), SHA256: strings.Repeat("b", 64), Key: "control/box-fences/" + strings.Repeat("a", 32) + ".json"} |
| 19 | sum := sha256.Sum256([]byte(c.Op.ID + "\x00box-b\x00" + ref.Enrollment + "\x00" + ref.SHA256)) |
| 20 | id := "auto-" + hex.EncodeToString(sum[:16]) |
| 21 | c.LossRefs = []string{id} |
| 22 | c.EvidenceRefs = []hostclaim.AutomaticLoss{{ID: id, Box: "box-b", Enrollment: ref.Enrollment, Fence: ref, ObservedAt: f.now.Format(time.RFC3339Nano), Op: c.Op.ID, Kind: hostclaim.KindLanding, Phase: hostclaim.PhaseProbing, Role: "target", Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, Timers: hostclaim.TimersUnknown}} |
| 23 | return nil |
| 24 | }) |
| 25 | // The raw fixture below supplies a retained evidence row; validation of the |
| 26 | // row belongs to ReportAutomaticLosses, whose failure is the point here. |
| 27 | if err != nil { |
| 28 | _ = o |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | noWork := func(context.Context) error { return nil } |
| 32 | wrong := func(context.Context, hostclaim.Observed) error { t.Fatal("generic recovery"); return nil } |
| 33 | failure := errors.New("loss signer unavailable") |
| 34 | calls := 0 |
| 35 | pages := 0 |
| 36 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(_ string, _ []byte) error { pages++; return nil }}) |
| 37 | t.Cleanup(func() { alerting.Arm(nil) }) |
| 38 | r := Recovery{Controller: Controller{Store: f.raw, Control: f.w, Authority: &f.authority, Now: func() time.Time { return f.now }, NewID: func() string { return "recovery" }}, Purge: wrong, Placement: wrong, SweepRecords: noWork, SweepPurges: noWork, SweepAdmissions: noWork} |
| 39 | r.ReportAutomaticLosses = func(ctx context.Context, host string) error { |
| 40 | before, err := hostclaim.Read(ctx, f.raw, host) |
| 41 | if err != nil { |
| 42 | return err |
| 43 | } |
| 44 | _, err = hostclaim.Update(ctx, f.raw, before, func(c *hostclaim.Claim) error { |
| 45 | if c.Op.Journal == nil { |
| 46 | c.Op.Journal = map[string]string{} |
| 47 | } |
| 48 | c.Op.Journal["report-attempt"] = "failed" |
| 49 | return nil |
| 50 | }) |
| 51 | if err != nil { |
| 52 | return err |
| 53 | } |
| 54 | return failure |
| 55 | } |
| 56 | r.Moves = func(ctx context.Context, g *MoveRecoveryGrant) error { |
| 57 | calls++ |
| 58 | fresh, err := hostclaim.Read(ctx, f.raw, g.Observation().Host) |
| 59 | if err != nil { |
| 60 | return err |
| 61 | } |
| 62 | if fresh.ETag != g.Observation().ClaimETag || fresh.Claim.Op.Journal["report-attempt"] != "failed" { |
| 63 | t.Fatal("recovery used observation before report") |
| 64 | } |
| 65 | return nil |
| 66 | } |
| 67 | if err := f.w.RunRecovery(t.Context(), r.Pass); !errors.Is(err, failure) || calls != 1 || pages != 1 { |
| 68 | t.Fatalf("pass=%v recovery calls=%d", err, calls) |
| 69 | } |
| 70 | } |
| 71 | |