| 1 | package boxvolumes |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/aws/aws-sdk-go-v2/aws" |
| 11 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 12 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 13 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 14 | smithymiddleware "github.com/aws/smithy-go/middleware" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/boxid" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | var ( |
| 21 | cloud = boxid.Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"} |
| 22 | entry = Entry{Box: "annie-welding", Enrollment: "enr-annie-1", Cloud: cloud} |
| 23 | writer = WrittenBy{Box: "bert-sawing", Enrollment: "enr-bert-1"} |
| 24 | t0 = time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 25 | ) |
| 26 | |
| 27 | type vol struct { |
| 28 | dev, id string |
| 29 | dot bool |
| 30 | } |
| 31 | |
| 32 | func reply(owner string, state types.InstanceStateName, vols ...vol) *ec2.DescribeInstancesOutput { |
| 33 | var maps []types.InstanceBlockDeviceMapping |
| 34 | for _, v := range vols { |
| 35 | maps = append(maps, types.InstanceBlockDeviceMapping{DeviceName: aws.String(v.dev), |
| 36 | Ebs: &types.EbsInstanceBlockDevice{VolumeId: aws.String(v.id), DeleteOnTermination: aws.Bool(v.dot), Status: types.AttachmentStatusAttached}}) |
| 37 | } |
| 38 | var md smithymiddleware.Metadata |
| 39 | awsmiddleware.SetRequestIDMetadata(&md, "req-1") |
| 40 | return &ec2.DescribeInstancesOutput{ResultMetadata: md, Reservations: []types.Reservation{{OwnerId: aws.String(owner), |
| 41 | Instances: []types.Instance{{InstanceId: aws.String("i-0abc"), State: &types.InstanceState{Name: state}, BlockDeviceMappings: maps}}}}} |
| 42 | } |
| 43 | |
| 44 | var root, data, scratch = vol{"/dev/xvda", "vol-root", true}, vol{"/dev/xvdb", "vol-data", true}, vol{"/dev/xvdc", "vol-keep", false} |
| 45 | |
| 46 | func observe(t *testing.T, out *ec2.DescribeInstancesOutput) Observation { |
| 47 | t.Helper() |
| 48 | o, err := Observe(out, cloud) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | return o |
| 53 | } |
| 54 | |
| 55 | func TestObserveReadsEBSMappingsInVolumeOrder(t *testing.T) { |
| 56 | out := reply("111122223333", types.InstanceStateNameRunning, root, data) |
| 57 | out.Reservations[0].Instances[0].BlockDeviceMappings = append(out.Reservations[0].Instances[0].BlockDeviceMappings, |
| 58 | types.InstanceBlockDeviceMapping{DeviceName: aws.String("/dev/sdz")}) // instance store: no Ebs |
| 59 | o := observe(t, out) |
| 60 | want := []Device{{"/dev/xvdb", "vol-data", true, "attached"}, {"/dev/xvda", "vol-root", true, "attached"}} |
| 61 | if o.State != "running" || o.RequestID != "req-1" || len(o.Devices) != 2 || o.Devices[0] != want[0] || o.Devices[1] != want[1] { |
| 62 | t.Fatalf("observation = %+v", o) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // Review Focus 3: a terminated instance's reply has no mappings. Recorded, |
| 67 | // it would claim every volume was removed; it must not be recordable. |
| 68 | func TestObserveRefusesWhatIsNotTheInstancesMapping(t *testing.T) { |
| 69 | for name, out := range map[string]*ec2.DescribeInstancesOutput{ |
| 70 | "terminated": reply("111122223333", types.InstanceStateNameTerminated), |
| 71 | "another owner": reply("999999999999", types.InstanceStateNameRunning, root), |
| 72 | "another instance": {Reservations: []types.Reservation{{OwnerId: aws.String("111122223333"), Instances: []types.Instance{{InstanceId: aws.String("i-0zzz")}}}}}, |
| 73 | "empty reply": {}, |
| 74 | "nil reply": nil, |
| 75 | } { |
| 76 | t.Run(name, func(t *testing.T) { |
| 77 | if o, err := Observe(out, cloud); !errors.Is(err, ErrNotRecordable) { |
| 78 | t.Fatalf("observation = %+v, err = %v; want ErrNotRecordable", o, err) |
| 79 | } |
| 80 | }) |
| 81 | } |
| 82 | // A shutting-down read still lists the mappings and may be recorded. |
| 83 | if o := observe(t, reply("111122223333", types.InstanceStateNameShuttingDown, root)); len(o.Devices) != 1 { |
| 84 | t.Fatalf("shutting-down: %+v", o) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestNextWritesOnChangeAndAtLeastHourly(t *testing.T) { |
| 89 | first, ok := Next(nil, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), t0) |
| 90 | if !ok || first.ObservedAt != "2026-10-06T09:00:00Z" || len(first.RemovedVolumes) != 0 || first.RemovedVolumes == nil { |
| 91 | t.Fatalf("first = %+v ok=%v", first, ok) |
| 92 | } |
| 93 | same := observe(t, reply("111122223333", types.InstanceStateNameRunning, root)) |
| 94 | if _, ok := Next(&first, entry, writer, same, t0.Add(59*time.Minute)); ok { |
| 95 | t.Fatal("wrote an unchanged mapping inside the hour") |
| 96 | } |
| 97 | if _, ok := Next(&first, entry, writer, same, t0.Add(time.Hour)); !ok { |
| 98 | t.Fatal("no record after an hour") |
| 99 | } |
| 100 | grown, ok := Next(&first, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root, scratch)), t0.Add(time.Minute)) |
| 101 | if !ok || len(grown.BlockDevices) != 2 { |
| 102 | t.Fatalf("an attached volume was not recorded: %+v", grown) |
| 103 | } |
| 104 | shrunk, ok := Next(&grown, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), t0.Add(2*time.Minute)) |
| 105 | if !ok || len(shrunk.RemovedVolumes) != 1 || shrunk.RemovedVolumes[0] != "vol-keep" { |
| 106 | t.Fatalf("a detached volume is not in removed_volumes: %+v", shrunk) |
| 107 | } |
| 108 | if _, ok := Next(&first, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameShuttingDown, root)), t0.Add(time.Minute)); !ok { |
| 109 | t.Fatal("a state change was not recorded") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestWriteIsCreateOnlyAndListReturnsObservationOrder(t *testing.T) { |
| 114 | ctx, st := context.Background(), release.NewDirStore(t.TempDir()) |
| 115 | a, _ := Next(nil, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), t0) |
| 116 | b, _ := Next(&a, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root, data)), t0.Add(10*time.Minute)) |
| 117 | for _, r := range []Record{b, a} { |
| 118 | if _, err := Write(ctx, st, r); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | } |
| 122 | if _, err := Write(ctx, st, a); err != nil { |
| 123 | t.Fatalf("the same record twice: %v", err) |
| 124 | } |
| 125 | other := a |
| 126 | other.RequestID = "req-other" |
| 127 | if _, err := Write(ctx, st, other); !errors.Is(err, release.ErrCASConflict) { |
| 128 | t.Fatalf("a different record under the same key: err = %v", err) |
| 129 | } |
| 130 | got, err := List(ctx, st, entry.Enrollment) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if len(got) != 2 || got[0].ObservedAt != a.ObservedAt || got[1].ObservedAt != b.ObservedAt { |
| 135 | t.Fatalf("list = %+v", got) |
| 136 | } |
| 137 | if _, err := Write(ctx, st, Record{Enrollment: "../edges", ObservedAt: a.ObservedAt}); err == nil { |
| 138 | t.Fatal("wrote outside the prefix") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestListFailsOnARecordItCannotRead(t *testing.T) { |
| 143 | ctx, st := context.Background(), release.NewDirStore(t.TempDir()) |
| 144 | if _, err := st.Put(ctx, Key(entry.Enrollment, t0), []byte(`{"v":1,"surprise":true}`), ""); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if _, err := List(ctx, st, entry.Enrollment); err == nil { |
| 148 | t.Fatal("an unreadable record read as a gap") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func records(t *testing.T, steps ...[]vol) []Record { |
| 153 | t.Helper() |
| 154 | var out []Record |
| 155 | var prev *Record |
| 156 | for i, vs := range steps { |
| 157 | rec, _ := Next(prev, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, vs...)), t0.Add(time.Duration(i)*10*time.Minute)) |
| 158 | out = append(out, rec) |
| 159 | prev = &out[len(out)-1] |
| 160 | } |
| 161 | return out |
| 162 | } |
| 163 | |
| 164 | func TestDischarge(t *testing.T) { |
| 165 | enrolled := t0.Add(-5 * time.Minute) |
| 166 | terminated := t0.Add(30 * time.Minute) |
| 167 | if v := Discharge(records(t, []vol{root}, []vol{root, data}, []vol{root, data}), entry, enrolled, terminated); !v.Discharge { |
| 168 | t.Fatalf("all true, nothing removed: %+v", v) |
| 169 | } |
| 170 | cases := map[string]struct { |
| 171 | recs []Record |
| 172 | at time.Time |
| 173 | }{ |
| 174 | "no record at all": {nil, enrolled}, |
| 175 | "a false flag": {records(t, []vol{root}, []vol{root, scratch}), enrolled}, |
| 176 | "a removed volume": {records(t, []vol{root, data}, []vol{root}), enrolled}, |
| 177 | "a replaced root volume": {records(t, []vol{root}, []vol{{"/dev/xvda", "vol-root2", true}}), enrolled}, |
| 178 | "first record long after": {records(t, []vol{root}), t0.Add(-2 * time.Hour)}, |
| 179 | "only another enrollment's": {func() []Record { r := records(t, []vol{root}); r[0].Enrollment = "enr-other"; return r }(), enrolled}, |
| 180 | "only another instance's": {func() []Record { r := records(t, []vol{root}); r[0].Cloud.InstanceID = "i-0zzz"; return r }(), enrolled}, |
| 181 | } |
| 182 | for name, tc := range cases { |
| 183 | t.Run(name, func(t *testing.T) { |
| 184 | if v := Discharge(tc.recs, entry, tc.at, terminated); v.Discharge || v.Reason == "" { |
| 185 | t.Fatalf("verdict = %+v, want the chores left open with a reason", v) |
| 186 | } |
| 187 | }) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // A removal is caught from the sequence even when the record that should |
| 192 | // have listed it was never written (two workers, one missed write). |
| 193 | func TestDischargeSeesARemovalNoRecordListed(t *testing.T) { |
| 194 | recs := records(t, []vol{root, data}, []vol{root}) |
| 195 | recs[1].RemovedVolumes = []string{} |
| 196 | if v := Discharge(recs, entry, t0, t0.Add(time.Hour)); v.Discharge { |
| 197 | t.Fatal("discharged across a silent removal") |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // Controller ruling, 2026-10-06: the newest record must be no more than |
| 202 | // LastRecordWindow older than the termination observation. A recorder that |
| 203 | // stopped long before the box died cannot speak for its last hours. |
| 204 | func TestDischargeNeedsARecentLastRecord(t *testing.T) { |
| 205 | recs := records(t, []vol{root}, []vol{root}) // newest at t0+10m |
| 206 | newest := t0.Add(10 * time.Minute) |
| 207 | if v := Discharge(recs, entry, t0, newest.Add(LastRecordWindow)); !v.Discharge { |
| 208 | t.Fatalf("exactly at the window: %+v", v) |
| 209 | } |
| 210 | if v := Discharge(recs, entry, t0, newest.Add(LastRecordWindow+time.Second)); v.Discharge || v.Reason == "" { |
| 211 | t.Fatalf("a newest record older than the window discharged: %+v", v) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // Astra plan R1-6: what List returns is whatever is stored. A stored record |
| 216 | // that is not evidence of a running instance's mappings keeps the chores |
| 217 | // open, however it got there. Each bad record sits beside a good one so |
| 218 | // that no removal is seen between them: only the record's own check can |
| 219 | // refuse it. |
| 220 | func TestDischargeRefusesStoredRecordsThatAreNotEvidence(t *testing.T) { |
| 221 | ctx := context.Background() |
| 222 | good := records(t, []vol{root}) |
| 223 | const before = `"enrollment":"enr-annie-1","cloud":{"provider":"aws","account":"111122223333","region":"eu-west-1","instance_id":"i-0abc"},"observed_at":"2026-10-06T08:55:00Z"` |
| 224 | const after = `"enrollment":"enr-annie-1","cloud":{"provider":"aws","account":"111122223333","region":"eu-west-1","instance_id":"i-0abc"},"observed_at":"2026-10-06T09:05:00Z"` |
| 225 | type bad struct { |
| 226 | body string |
| 227 | at time.Duration // from t0; the good record is at t0 |
| 228 | } |
| 229 | cases := map[string]bad{ |
| 230 | // Before the good record: an empty mapping followed by one volume is |
| 231 | // an attach, not a removal. |
| 232 | "only the binding fields": {`{"v":1,` + before + `}`, -5 * time.Minute}, |
| 233 | "a terminated read": {`{"v":1,` + before + `,"instance_state":"terminated","block_devices":[],"removed_volumes":[]}`, -5 * time.Minute}, |
| 234 | // After it, keeping vol-root, so nothing is removed. |
| 235 | "a volume with no id": {`{"v":1,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"},{"device":"/dev/xvdb","volume_id":"","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| 236 | "a version 2 record": {`{"v":2,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| 237 | // Astra plan R2-2: one case per check, each failing only that check. |
| 238 | "a running read with no volume": {`{"v":1,` + before + `,"instance_state":"running","block_devices":[],"removed_volumes":[]}`, -5 * time.Minute}, |
| 239 | "a terminated read with its root mapping": {`{"v":1,` + after + `,"instance_state":"terminated","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| 240 | "a volume with no device": {`{"v":1,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"},{"device":"","volume_id":"vol-data","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| 241 | } |
| 242 | const goodAfter = `{"v":1,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}` |
| 243 | swap := func(old, new string) string { |
| 244 | if !strings.Contains(goodAfter, old) { |
| 245 | t.Fatalf("no %q in the template", old) |
| 246 | } |
| 247 | return strings.Replace(goodAfter, old, new, 1) |
| 248 | } |
| 249 | cases["a detaching volume"] = bad{swap(`"status":"attached"`, `"status":"detaching"`), 5 * time.Minute} |
| 250 | cases["a detached volume"] = bad{swap(`"status":"attached"`, `"status":"detached"`), 5 * time.Minute} |
| 251 | cases["no status"] = bad{swap(`"status":"attached"`, `"status":""`), 5 * time.Minute} |
| 252 | cases["no enrollment"] = bad{swap(`"enrollment":"enr-annie-1"`, `"enrollment":""`), 5 * time.Minute} |
| 253 | cases["another enrollment"] = bad{swap(`"enrollment":"enr-annie-1"`, `"enrollment":"enr-other"`), 5 * time.Minute} |
| 254 | cases["another instance"] = bad{swap(`"instance_id":"i-0abc"`, `"instance_id":"i-0zzz"`), 5 * time.Minute} |
| 255 | cases["a region in another case"] = bad{swap(`"region":"eu-west-1"`, `"region":"EU-WEST-1"`), 5 * time.Minute} |
| 256 | cases["an unlabelled version 2 record"] = bad{`{"v":2}`, 5 * time.Minute} |
| 257 | for name, tc := range cases { |
| 258 | t.Run(name, func(t *testing.T) { |
| 259 | st := release.NewDirStore(t.TempDir()) |
| 260 | if _, err := Write(ctx, st, good[0]); err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | if _, err := st.Put(ctx, Key(entry.Enrollment, t0.Add(tc.at)), []byte(tc.body), ""); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | recs, err := List(ctx, st, entry.Enrollment) |
| 267 | if err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | if len(recs) != 2 { |
| 271 | t.Fatalf("listed %d records, want 2", len(recs)) |
| 272 | } |
| 273 | if v := Discharge(recs, entry, t0, t0.Add(time.Hour)); v.Discharge || v.Reason == "" { |
| 274 | t.Fatalf("verdict = %+v, want the chores left open", v) |
| 275 | } |
| 276 | }) |
| 277 | } |
| 278 | // The good record alone, stored and listed, still discharges. |
| 279 | st := release.NewDirStore(t.TempDir()) |
| 280 | if _, err := Write(ctx, st, good[0]); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | recs, _ := List(ctx, st, entry.Enrollment) |
| 284 | if v := Discharge(recs, entry, t0, t0.Add(time.Hour)); !v.Discharge { |
| 285 | t.Fatalf("a stored good record: %+v", v) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // A gap longer than LastRecordWindow between two records is a stretch the |
| 290 | // recorder did not cover. |
| 291 | func TestDischargeRefusesAnInteriorGap(t *testing.T) { |
| 292 | a, _ := Next(nil, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), t0) |
| 293 | late := t0.Add(30 * 24 * time.Hour) |
| 294 | b, _ := Next(&a, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), late) |
| 295 | if v := Discharge([]Record{a, b}, entry, t0, late.Add(time.Hour)); v.Discharge || v.Reason == "" { |
| 296 | t.Fatalf("a 30 day gap discharged: %+v", v) |
| 297 | } |
| 298 | c, _ := Next(&a, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root)), t0.Add(LastRecordWindow)) |
| 299 | if v := Discharge([]Record{a, c}, entry, t0, t0.Add(LastRecordWindow)); !v.Discharge { |
| 300 | t.Fatalf("a gap of exactly the window: %+v", v) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // Records must lie between enrollment and termination, and both times must |
| 305 | // be real and in order. |
| 306 | func TestDischargeRefusesTimesThatDoNotFit(t *testing.T) { |
| 307 | recs := records(t, []vol{root}, []vol{root}) // t0, t0+10m |
| 308 | var zero time.Time |
| 309 | cases := map[string]struct{ enrolled, terminated time.Time }{ |
| 310 | "zero termination": {t0, zero}, |
| 311 | "zero enrollment": {zero, t0.Add(time.Hour)}, |
| 312 | "terminated before enrolled": {t0.Add(time.Hour), t0}, |
| 313 | "a record before enrollment": {t0.Add(time.Minute), t0.Add(time.Hour)}, |
| 314 | "a record after termination": {t0, t0.Add(5 * time.Minute)}, |
| 315 | } |
| 316 | for name, tc := range cases { |
| 317 | t.Run(name, func(t *testing.T) { |
| 318 | if v := Discharge(recs, entry, tc.enrolled, tc.terminated); v.Discharge || v.Reason == "" { |
| 319 | t.Fatalf("verdict = %+v", v) |
| 320 | } |
| 321 | }) |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | // Final review 1: as EC2 shuts an instance down, its DeleteOnTermination |
| 326 | // volumes show "detaching" on the way to deletion. A shutting-down record |
| 327 | // listing them is the normal last record of a box and must discharge; a |
| 328 | // detaching volume with a false flag, or one in any other state, must not. |
| 329 | func TestDischargeAcceptsDetachingDeleteOnTerminationVolumesWhileShuttingDown(t *testing.T) { |
| 330 | running, _ := Next(nil, entry, writer, observe(t, reply("111122223333", types.InstanceStateNameRunning, root, data)), t0) |
| 331 | shutting := func(dot bool) Record { |
| 332 | out := reply("111122223333", types.InstanceStateNameShuttingDown, root, vol{data.dev, data.id, dot}) |
| 333 | for _, m := range out.Reservations[0].Instances[0].BlockDeviceMappings { |
| 334 | m.Ebs.Status = types.AttachmentStatusDetaching |
| 335 | } |
| 336 | rec, ok := Next(&running, entry, writer, observe(t, out), t0.Add(10*time.Minute)) |
| 337 | if !ok { |
| 338 | t.Fatal("the shutting-down read was not a new record") |
| 339 | } |
| 340 | return rec |
| 341 | } |
| 342 | if v := Discharge([]Record{running, shutting(true)}, entry, t0, t0.Add(15*time.Minute)); !v.Discharge { |
| 343 | t.Fatalf("shutting down, every volume detaching and DeleteOnTermination: %+v", v) |
| 344 | } |
| 345 | if v := Discharge([]Record{running, shutting(false)}, entry, t0, t0.Add(15*time.Minute)); v.Discharge || v.Reason == "" { |
| 346 | t.Fatalf("shutting down with a detaching volume that is not DeleteOnTermination: %+v", v) |
| 347 | } |
| 348 | if reason := incomplete(shutting(false)); reason == "" { |
| 349 | t.Fatal("incomplete accepted a detaching volume with a false flag") |
| 350 | } |
| 351 | stopping := shutting(true) |
| 352 | stopping.InstanceState = "stopping" |
| 353 | if v := Discharge([]Record{running, stopping}, entry, t0, t0.Add(15*time.Minute)); v.Discharge || v.Reason == "" { |
| 354 | t.Fatalf("a stopping record with detaching volumes: %+v", v) |
| 355 | } |
| 356 | detached := shutting(true) |
| 357 | detached.BlockDevices[0].Status = "detached" |
| 358 | if v := Discharge([]Record{running, detached}, entry, t0, t0.Add(15*time.Minute)); v.Discharge || v.Reason == "" { |
| 359 | t.Fatalf("a shutting-down record with a detached volume: %+v", v) |
| 360 | } |
| 361 | } |
| 362 | |